From 4be5bcd756219607ec13685ee1f81eeb3da27e98 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:43:37 -0700 Subject: [PATCH] feat(memory-safety): bound OOM-prone ingress across transports, parsers and scans Re-scoped from the reverted #10179 to the bound-at-ingress doctrine (see src/shared/memory-safety/README.md): ceilings go where untrusted or unpredictably-large input first enters a heap, not in the interior. Kept: daemon/relay/SSH/mobile/IPC transport frames, PTY output flow control, on-disk agent transcripts, document parsing, filesystem enumeration, provider pagination, media decode, and long-lived accumulators keyed by user-growable ids -- the class behind the reported renderer OOM (#9872). Dropped: interior ceilings whose size is already implied by a bounded input, second ceilings behind existing ones, and derived/presentational state. Every file is reconciled against current main: check-no-merge-base-revert reports 0 reverted and 0 missing across 169 overlapping files, so no shipped work is lost. Typecheck is at main's own 2-error baseline. Co-authored-by: Orca --- .github/workflows/computer-e2e.yml | 4 + .github/workflows/pr.yml | 8 + config/bounded-ingress-baseline.json | 314 +---- config/scripts/check-no-merge-base-revert.mjs | 150 +++ .../check-no-merge-base-revert.test.mjs | 45 + config/tsconfig.cli.json | 8 + .../app/h/[hostId]/session/[worktreeId].tsx | 325 ++--- mobile/app/h/[hostId]/tasks.tsx | 176 ++- .../scripts/bounded-process-line-reader.mjs | 103 ++ mobile/scripts/bounded-response-body.mjs | 36 + .../start-emulator-pairing-runtime.mjs | 27 +- mobile/scripts/start-emulator.mjs | 57 +- mobile/src/browser/MobileBrowserPane.tsx | 77 +- .../mobile-browser-frame-cache.test.ts | 68 + .../src/browser/mobile-browser-frame-cache.ts | 134 ++ .../src/cache/mobile-rpc-list-cache.test.ts | 50 + mobile/src/cache/mobile-rpc-list-cache.ts | 126 ++ mobile/src/cache/repo-cache.test.ts | 22 +- mobile/src/cache/repo-cache.ts | 42 +- mobile/src/cache/worktree-cache.test.ts | 22 +- mobile/src/cache/worktree-cache.ts | 45 +- .../pr-sidebar/markdown-blocks.test.ts | 38 +- .../components/pr-sidebar/markdown-blocks.ts | 80 +- .../src/files/MobileFileExplorerPanel.test.ts | 50 + mobile/src/files/MobileFileExplorerPanel.tsx | 130 +- .../files/directory-load-revisions.test.ts | 14 + mobile/src/files/directory-load-revisions.ts | 15 + mobile/src/files/file-list-fallback.test.ts | 162 ++- mobile/src/files/file-list-fallback.ts | 204 ++- mobile/src/files/file-tree.ts | 1 + .../files/mobile-diff-image-preview.test.ts | 27 +- .../mobile-directory-cache-retention.test.ts | 98 ++ .../files/mobile-directory-cache-retention.ts | 156 +++ .../files/mobile-file-preview-request.test.ts | 41 +- .../src/files/mobile-file-preview-response.ts | 7 +- mobile/src/files/mobile-file-tab-doc.test.ts | 28 +- .../mobile-dictation-audio-chunk.test.ts | 42 + .../src/hooks/mobile-dictation-audio-chunk.ts | 9 + .../session/ai-vault-resume-launch.test.ts | 45 - mobile/src/session/ai-vault-resume-launch.ts | 34 +- .../mobile-image-base64-accumulator.test.ts | 17 + .../mobile-image-base64-accumulator.ts | 40 + .../mobile-image-source-picker.test.ts | 132 +- .../src/session/mobile-image-source-picker.ts | 91 +- .../mobile-session-file-doc-lifecycle.test.ts | 128 ++ .../mobile-session-file-doc-lifecycle.ts | 242 ++++ ...ile-session-markdown-doc-lifecycle.test.ts | 106 ++ .../mobile-session-markdown-doc-lifecycle.ts | 147 +++ .../mobile-session-startup-source.test.ts | 39 +- ...use-mobile-native-chat-file-search.test.ts | 17 +- .../use-mobile-native-chat-file-search.ts | 41 +- ...bile-native-chat-image-attachments.test.ts | 130 +- ...se-mobile-native-chat-image-attachments.ts | 80 +- .../use-mobile-session-image-attachments.ts | 8 +- .../mobile-pr-file-content-cache.test.ts | 153 +++ .../src/tasks/mobile-pr-file-content-cache.ts | 226 ++++ .../tasks/use-mobile-pr-file-content-cache.ts | 183 +++ .../terminal-webview-default-theme.ts | 27 + mobile/src/terminal/terminal-webview-html.ts | 81 +- .../terminal-webview-init-surface.test.ts | 52 +- .../terminal-webview-text-zoom.test.ts | 3 +- .../bounded-process-line-reader.test.ts | 46 + .../transport/bounded-response-body.test.ts | 34 + .../transport/browser-screencast-protocol.ts | 4 +- .../transport/connection-log-buffer.test.ts | 79 +- mobile/src/transport/connection-log-buffer.ts | 168 ++- mobile/src/transport/e2ee.ts | 19 +- .../host-client-open-registry.test.ts | 20 + .../transport/host-client-open-registry.ts | 20 + .../transport/host-credential-cleanup.test.ts | 39 + .../src/transport/host-credential-cleanup.ts | 92 +- .../transport/host-removal-lifecycle.test.ts | 23 +- .../src/transport/host-removal-lifecycle.ts | 7 +- mobile/src/transport/host-store.test.ts | 108 ++ mobile/src/transport/host-store.ts | 58 +- .../mobile-direct-rpc-outbound.test.ts | 177 +++ .../transport/mobile-direct-rpc-outbound.ts | 126 ++ .../src/transport/mobile-direct-rpc-sender.ts | 51 + .../mobile-e2ee-legacy-fixtures.test.ts | 19 +- .../mobile-e2ee-v2-client-session.test.ts | 21 +- .../mobile-e2ee-v2-client-session.ts | 13 + .../mobile-e2ee-v2-physical-channel.test.ts | 118 +- .../mobile-e2ee-v2-physical-channel.ts | 136 +- .../src/transport/mobile-host-list-storage.ts | 43 + .../mobile-inbound-frame-queue-memory.test.ts | 154 +++ .../mobile-inbound-frame-queue.test.ts | 116 ++ .../transport/mobile-inbound-frame-queue.ts | 209 ++++ .../mobile-inbound-memory-ledger.test.ts | 43 + .../transport/mobile-inbound-memory-ledger.ts | 58 + .../mobile-json-text-admission.test.ts | 61 + .../transport/mobile-json-text-admission.ts | 38 + .../transport/mobile-outbound-json.test.ts | 72 ++ mobile/src/transport/mobile-outbound-json.ts | 123 ++ .../mobile-outbound-memory-budget.test.ts | 106 ++ .../mobile-outbound-memory-budget.ts | 188 +++ .../mobile-outbound-socket-ledger.test.ts | 117 ++ .../mobile-outbound-socket-ledger.ts | 151 +++ .../mobile-relay-credential-bundle.test.ts | 47 + .../mobile-relay-credential-bundle.ts | 36 +- ...obile-relay-direct-upgrade-journal.test.ts | 78 ++ .../mobile-relay-direct-upgrade-journal.ts | 30 +- .../src/transport/mobile-relay-e2ee-link.ts | 60 +- .../mobile-relay-host-overlay-store.test.ts | 25 + .../mobile-relay-host-overlay-store.ts | 34 +- .../transport/mobile-relay-host-overlay.ts | 4 +- .../mobile-relay-invite-director.test.ts | 25 +- .../transport/mobile-relay-invite-director.ts | 11 +- ...mobile-relay-pairing-journal-store.test.ts | 84 +- .../mobile-relay-pairing-journal-store.ts | 46 +- .../transport/mobile-relay-pairing-journal.ts | 30 +- .../mobile-relay-physical-client.test.ts | 21 +- .../transport/mobile-relay-physical-client.ts | 124 +- .../mobile-relay-resume-director.test.ts | 23 + .../transport/mobile-relay-resume-director.ts | 22 +- .../src/transport/mobile-relay-rpc-session.ts | 20 +- .../mobile-relay-rpc-streams.test.ts | 152 --- .../src/transport/mobile-relay-rpc-streams.ts | 23 +- .../mobile-rpc-binary-frame-handler.ts | 18 + mobile/src/transport/pairing.test.ts | 34 +- mobile/src/transport/pairing.ts | 26 +- .../transport/redacted-websocket-endpoint.ts | 9 + .../rpc-client-terminal-binary-frame.test.ts | 88 ++ .../rpc-client-terminal-binary-frame.ts | 61 +- mobile/src/transport/rpc-client.test.ts | 19 + mobile/src/transport/rpc-client.ts | 159 +-- .../runtime-capability-probe.test.ts | 48 - .../src/transport/runtime-capability-probe.ts | 13 +- .../src/transport/terminal-stream-protocol.ts | 6 +- mobile/src/transport/types.ts | 36 +- .../transport/websocket-payload-bytes.test.ts | 52 +- .../src/transport/websocket-payload-bytes.ts | 57 +- .../last-visited-worktree-repo.test.ts | 43 +- .../worktree/last-visited-worktree-repo.ts | 35 +- src/cli/computer-screenshot-storage.test.ts | 44 + src/cli/computer-screenshot-storage.ts | 96 ++ src/cli/handlers/agent-hooks.test.ts | 18 +- src/cli/handlers/agent-hooks.ts | 15 +- src/cli/handlers/computer-action-flags.ts | 19 +- src/cli/handlers/orchestration.ts | 125 +- src/cli/handlers/vm.ts | 6 +- src/cli/linear-request-builders.ts | 42 +- src/cli/runtime/launch.test.ts | 27 + src/cli/runtime/launch.ts | 99 +- src/cli/runtime/recipe-json-output.ts | 120 ++ src/cli/runtime/transport.test.ts | 36 +- src/cli/runtime/transport.ts | 55 +- .../agent-hooks/agent-hook-file-comparison.ts | 15 + .../agent-hooks/agent-hook-file-limits.ts | 6 + .../agent-hook-sftp-text-reader.ts | 151 +++ src/main/agent-hooks/hooks-json-read.ts | 39 +- .../installer-utils-remote.test.ts | 60 +- .../agent-hooks/installer-utils-remote.ts | 64 +- src/main/agent-hooks/installer-utils.ts | 29 +- .../managed-hook-local-filesystem.test.ts | 49 +- .../managed-hook-local-filesystem.ts | 61 +- .../agent-hooks/managed-hook-lock-claims.ts | 34 +- .../agent-hooks/managed-hook-lock-records.ts | 77 +- .../managed-hook-owner-identity.test.ts | 37 +- .../managed-hook-owner-identity.ts | 5 +- .../migration-unsupported-pty-state.test.ts | 44 + .../migration-unsupported-pty-state.ts | 17 + src/main/agent-hooks/server.test.ts | 43 + src/main/agent-hooks/server.ts | 322 ++++- .../wsl-guest-plugin-install.test.ts | 61 + .../agent-hooks/wsl-guest-plugin-install.ts | 45 + src/main/agent-hooks/wsl-hook-fs-adapter.ts | 82 +- src/main/agent-hooks/wsl-hook-relay-deps.ts | 6 + .../wsl-hook-relay-manager.test.ts | 31 +- .../agent-hooks/wsl-hook-relay-manager.ts | 36 +- .../wsl-hook-relay-sentinel.test.ts | 26 +- .../agent-hooks/wsl-hook-relay-sentinel.ts | 52 +- src/main/agent-trust-presets.test.ts | 117 +- src/main/agent-trust-presets.ts | 18 +- src/main/ai-vault/cached-session-list.ts | 34 +- .../claude-subagent-list-retention.ts | 19 + .../remote-session-scanner-codex-index.ts | 24 +- .../remote-session-scanner-discovery.ts | 63 +- .../ai-vault/remote-session-scanner-types.ts | 2 + .../ai-vault/remote-session-scanner.test.ts | 86 ++ src/main/ai-vault/remote-session-scanner.ts | 48 +- .../ai-vault/session-discovery-budget.test.ts | 14 + src/main/ai-vault/session-discovery-budget.ts | 56 + .../session-jsonl-line-reader.test.ts | 76 ++ .../ai-vault/session-jsonl-line-reader.ts | 123 ++ .../ai-vault/session-list-results.test.ts | 48 + src/main/ai-vault/session-list-results.ts | 49 +- .../ai-vault/session-list-retention.test.ts | 138 +++ src/main/ai-vault/session-list-retention.ts | 321 +++++ .../session-parse-cache-payload.test.ts | 119 ++ .../ai-vault/session-parse-cache-payload.ts | 66 + .../session-parse-cache-persistence.ts | 30 +- .../session-parse-cache-retention.test.ts | 149 +++ .../ai-vault/session-parse-cache-retention.ts | 180 +++ .../ai-vault/session-scanner-accumulator.ts | 41 +- .../session-scanner-antigravity-parser.ts | 8 +- .../session-scanner-claude-cwd-drift.test.ts | 59 - .../session-scanner-claude-subagents.test.ts | 106 ++ .../session-scanner-claude-subagents.ts | 194 ++- .../ai-vault/session-scanner-codex-parser.ts | 10 +- .../session-scanner-codex-title-index.test.ts | 40 + .../session-scanner-codex-title-index.ts | 128 +- .../ai-vault/session-scanner-devin-parser.ts | 11 +- .../session-scanner-discovery.test.ts | 162 +++ .../ai-vault/session-scanner-discovery.ts | 249 ++-- .../ai-vault/session-scanner-droid-parser.ts | 8 +- .../session-scanner-gemini-parsers.ts | 16 +- .../ai-vault/session-scanner-graph-parsers.ts | 75 +- .../ai-vault/session-scanner-grok-parser.ts | 47 +- .../session-scanner-kimi-index-cache.test.ts | 38 + .../session-scanner-kimi-index-cache.ts | 78 +- .../session-scanner-kimi-parser.test.ts | 19 + .../ai-vault/session-scanner-kimi-parser.ts | 84 +- .../ai-vault/session-scanner-kimi-paths.ts | 84 +- .../session-scanner-opencode-sources.ts | 36 +- ...ion-scanner-opencode-sqlite-bounds.test.ts | 107 +- .../session-scanner-opencode-sqlite-limits.ts | 5 + .../session-scanner-opencode-sqlite-list.ts | 5 +- ...nner-opencode-sqlite-worker-client.test.ts | 28 + ...n-scanner-opencode-sqlite-worker-client.ts | 6 + .../session-scanner-opencode-sqlite.ts | 48 +- .../session-scanner-parse-cache.test.ts | 24 + .../ai-vault/session-scanner-parse-cache.ts | 148 +-- .../session-scanner-primary-parsers.ts | 14 +- .../session-scanner-retention.test.ts | 41 + .../session-scanner-scope-discovery.ts | 126 +- .../session-scanner-secondary-parsers.ts | 136 +- .../session-scanner-subagent-transcripts.ts | 18 +- src/main/ai-vault/session-scanner-types.ts | 2 + .../ai-vault/session-scanner-values.test.ts | 12 +- src/main/ai-vault/session-scanner-values.ts | 30 +- src/main/ai-vault/session-scanner.ts | 28 +- .../ai-vault/session-scope-cwd-cache.test.ts | 32 + src/main/ai-vault/session-scope-cwd-cache.ts | 77 ++ .../session-whole-json-reader.test.ts | 93 ++ .../ai-vault/session-whole-json-reader.ts | 87 ++ src/main/amp/hook-service.test.ts | 6 + src/main/amp/hook-service.ts | 41 +- src/main/automations/external-job-mappers.ts | 1 + .../external-manager-remote-fanout.test.ts | 70 ++ src/main/automations/external-manager.test.ts | 2 + src/main/automations/external-manager.ts | 74 +- src/main/automations/headless-dispatch.ts | 3 +- .../automations/hermes-cron-output.test.ts | 74 +- src/main/automations/hermes-cron-output.ts | 197 ++- .../azure-devops/azure-devops-api-request.ts | 3 +- src/main/azure-devops/repository-ref.ts | 42 +- src/main/bitbucket/client.ts | 3 +- src/main/bitbucket/repository-ref.ts | 29 +- src/main/browser/agent-browser-bridge.test.ts | 191 ++- src/main/browser/agent-browser-bridge.ts | 104 +- .../browser-cookie-import.comet.test.ts | 53 +- .../browser-cookie-import.helium.test.ts | 44 +- .../browser/browser-cookie-import.test.ts | 222 +++- src/main/browser/browser-cookie-import.ts | 451 +++---- .../browser-cookie-json-file-parser.test.ts | 91 ++ .../browser-cookie-json-file-parser.ts | 184 +++ src/main/browser/browser-manager.test.ts | 20 +- src/main/browser/browser-manager.ts | 10 + .../browser/browser-pdf-admission.test.ts | 76 ++ src/main/browser/browser-pdf-admission.ts | 59 + .../browser/browser-screencast-stream.test.ts | 16 +- src/main/browser/browser-screencast-stream.ts | 21 +- .../browser-screenshot-admission.test.ts | 95 ++ .../browser/browser-screenshot-admission.ts | 64 + .../browser-screenshot-file-reader.test.ts | 36 + .../browser/browser-screenshot-file-reader.ts | 54 + .../browser/browser-screenshot-limits.test.ts | 31 + src/main/browser/browser-screenshot-limits.ts | 37 + src/main/browser/cdp-bridge.ts | 134 +- src/main/browser/cdp-command-queue.test.ts | 79 ++ src/main/browser/cdp-command-queue.ts | 80 ++ .../browser/cdp-event-memory-bounds.test.ts | 84 ++ src/main/browser/cdp-event-memory-bounds.ts | 145 +++ src/main/browser/cdp-print-to-pdf.test.ts | 103 +- src/main/browser/cdp-print-to-pdf.ts | 112 +- src/main/browser/cdp-screenshot.test.ts | 193 +++ src/main/browser/cdp-screenshot.ts | 215 ++-- src/main/browser/cdp-ws-proxy.test.ts | 47 +- src/main/browser/cdp-ws-proxy.ts | 24 +- ...talled-browser-cookie-store-limits.test.ts | 38 + .../installed-browser-cookie-store-limits.ts | 41 + .../native-image-screenshot-encoder.ts | 79 ++ .../safari-cookie-store-decoder.test.ts | 187 +++ .../browser/safari-cookie-store-decoder.ts | 212 ++++ src/main/browser/snapshot-engine.test.ts | 50 +- src/main/browser/snapshot-engine.ts | 274 +++-- src/main/cache-identity-digest.ts | 11 + .../claude-accounts/live-pty-gate.test.ts | 48 - src/main/claude-accounts/live-pty-gate.ts | 24 - src/main/claude-accounts/oauth-refresh.ts | 3 +- .../claude-accounts/runtime-auth-service.ts | 29 +- src/main/claude-accounts/service.ts | 7 +- .../scanner-large-directory.test.ts | 78 +- src/main/claude-usage/scanner.test.ts | 26 + src/main/claude-usage/scanner.ts | 189 ++- src/main/cli/cli-installer.test.ts | 45 +- src/main/cli/cli-installer.ts | 57 +- .../cli/linux-bare-orca-dispatcher.test.ts | 46 +- src/main/cli/linux-bare-orca-dispatcher.ts | 8 +- src/main/cli/wsl-cli-installer.test.ts | 10 +- src/main/cli/wsl-cli-installer.ts | 24 +- .../codex-wsl-runtime-home-retention.ts | 140 +++ .../legacy-history-migration.test.ts | 109 ++ .../legacy-history-migration.ts | 230 ++++ .../legacy-managed-state-migration.test.ts | 163 +++ .../legacy-managed-state-migration.ts | 317 +++++ .../legacy-session-migration.test.ts | 174 +++ .../legacy-session-migration.ts | 305 +++++ .../legacy-shared-auth-migration.ts | 5 +- src/main/codex-accounts/service.test.ts | 1087 +--------------- .../nvm-version-directory-discovery.test.ts | 93 ++ .../nvm-version-directory-discovery.ts | 100 ++ .../scanner-large-directory.test.ts | 58 +- src/main/codex-usage/scanner.test.ts | 40 +- src/main/codex-usage/scanner.ts | 179 ++- .../codex/codex-app-server-grant-bridge.ts | 9 +- .../codex/codex-app-server-grant-json.test.ts | 26 + src/main/codex/codex-app-server-grant-json.ts | 51 + src/main/codex/codex-app-server-session.ts | 50 +- src/main/codex/codex-home-paths.ts | 24 +- .../codex/codex-real-home-hook-install.ts | 7 +- .../codex/codex-session-file-listing.test.ts | 112 ++ src/main/codex/codex-session-file-listing.ts | 112 +- .../codex-session-index-heal-jsonl.test.ts | 45 + .../codex/codex-session-index-heal-jsonl.ts | 102 ++ .../codex/codex-session-index-heal-state.ts | 132 +- .../codex/codex-session-listing-budget.ts | 81 ++ .../codex/codex-trust-config-rollback.test.ts | 12 + src/main/codex/codex-trust-config-rollback.ts | 35 +- .../codex/codex-wsl-hook-install-plan.test.ts | 62 +- src/main/codex/codex-wsl-hook-install-plan.ts | 129 +- .../codex-wsl-reconciliation-generations.ts | 76 ++ src/main/codex/config-toml-trust.ts | 4 +- src/main/codex/hook-service.ts | 19 +- ...cos-computer-use-permission-status.test.ts | 85 +- .../macos-computer-use-permission-status.ts | 68 +- .../macos-computer-use-permissions.test.ts | 33 +- .../computer/macos-native-provider-client.ts | 33 +- .../macos-native-provider-transport.test.ts | 58 + .../macos-native-provider-transport.ts | 57 +- src/main/daemon/binary-frame.test.ts | 17 + src/main/daemon/binary-frame.ts | 4 + src/main/daemon/client.test.ts | 27 +- src/main/daemon/client.ts | 77 +- .../daemon/daemon-admission-limits.test.ts | 163 +++ src/main/daemon/daemon-admission-limits.ts | 293 +++++ .../daemon-client-outbound-admission.test.ts | 117 ++ .../daemon/daemon-control-file-reader.test.ts | 40 + src/main/daemon/daemon-control-file-reader.ts | 9 + src/main/daemon/daemon-entry.ts | 10 +- ...n-foreground-confirmation-protocol.test.ts | 2 +- .../daemon-health-socket-cleanup.test.ts | 18 +- src/main/daemon/daemon-health.ts | 135 +- src/main/daemon/daemon-host-relocation.ts | 77 +- src/main/daemon/daemon-init.test.ts | 72 ++ src/main/daemon/daemon-init.ts | 16 +- ...daemon-login-session-probe-verdict.test.ts | 33 + .../daemon-login-session-probe-verdict.ts | 13 + .../daemon/daemon-protocol-version.test.ts | 8 +- src/main/daemon/daemon-protocol-version.ts | 6 +- src/main/daemon/daemon-pty-adapter.test.ts | 142 ++- src/main/daemon/daemon-pty-adapter.ts | 15 +- src/main/daemon/daemon-pty-router.test.ts | 32 + src/main/daemon/daemon-pty-router.ts | 18 +- src/main/daemon/daemon-pty-size.ts | 10 +- src/main/daemon/daemon-response-admission.ts | 20 + .../daemon/daemon-server-admission.test.ts | 354 ++++++ src/main/daemon/daemon-server.ts | 248 +++- src/main/daemon/daemon-spawner.ts | 5 +- src/main/daemon/daemon-stream-data-batcher.ts | 66 +- src/main/daemon/daemon-stream-data-entry.ts | 9 +- ...emon-stream-droppability-lifecycle.test.ts | 235 ---- ...daemon-stream-droppable-membership.test.ts | 313 ----- .../daemon-stream-droppable-membership.ts | 64 - .../daemon/daemon-stream-keep-tail-drop.ts | 5 +- .../daemon/degraded-daemon-pty-provider.ts | 15 +- .../headless-emulator-size-limits.test.ts | 14 + src/main/daemon/headless-emulator.ts | 9 +- .../daemon/history-manager-memory.test.ts | 68 + src/main/daemon/history-manager.ts | 123 +- src/main/daemon/history-reader-memory.test.ts | 94 +- src/main/daemon/ndjson.test.ts | 55 +- src/main/daemon/ndjson.ts | 45 +- src/main/daemon/pty-subprocess.test.ts | 28 +- src/main/daemon/pty-subprocess.ts | 9 +- .../daemon/session-pending-output.test.ts | 21 +- .../session-pre-ready-input-memory.test.ts | 38 + src/main/daemon/session.test.ts | 14 + src/main/daemon/session.ts | 28 +- .../daemon/terminal-history-file-limits.ts | 6 +- .../daemon/terminal-history-file-reader.ts | 24 +- ...tory-large-checkpoint-cold-restore.test.ts | 120 -- src/main/daemon/terminal-history-log-state.ts | 55 + src/main/daemon/terminal-history-log.test.ts | 13 +- src/main/daemon/terminal-history-log.ts | 93 +- .../terminal-history-session-metadata.ts | 36 + ...terminal-host-session-reaping-leak.test.ts | 20 - .../daemon/terminal-session-teardown.test.ts | 119 -- src/main/daemon/terminal-session-teardown.ts | 7 +- src/main/daemon/types.ts | 1 + src/main/devin/hook-config-json.ts | 9 +- .../android/scrcpy-stream-session.test.ts | 17 + .../emulator/android/scrcpy-stream-session.ts | 13 +- .../emulator/android/uiautomator-tree.test.ts | 25 +- src/main/emulator/android/uiautomator-tree.ts | 37 +- src/main/emulator/mjpeg-frame-stream.ts | 36 +- .../scrcpy-video-registry-memory.test.ts | 194 +++ src/main/emulator/scrcpy-video-registry.ts | 133 +- .../serve-sim-accessibility-tree.test.ts | 24 +- .../emulator/serve-sim-accessibility-tree.ts | 9 +- .../serve-sim-detached-session.test.ts | 50 +- .../emulator/serve-sim-detached-session.ts | 13 +- src/main/emulator/serve-sim-execution.ts | 17 +- .../filesystem-provider-bounded-text.test.ts | 67 + src/main/filesystem-provider-bounded-text.ts | 34 + ...generated-node-bounded-file-reader.test.ts | 86 ++ .../generated-node-bounded-file-reader.ts | 30 + src/main/ghostty/index.test.ts | 91 +- src/main/ghostty/index.ts | 21 +- src/main/ghostty/theme-import.test.ts | 44 +- src/main/ghostty/theme-resolution.test.ts | 69 +- src/main/ghostty/theme-resolution.ts | 9 +- src/main/git/gh-rate-limit-breaker.ts | 4 +- src/main/git/git-capability-state.test.ts | 27 +- src/main/git/git-capability-state.ts | 17 + src/main/git/huge-folder-ignore.test.ts | 29 + src/main/git/huge-folder-ignore.ts | 93 +- src/main/git/repo.ts | 65 +- src/main/git/runner-wsl-gh-fallback.test.ts | 65 +- src/main/git/runner.ts | 69 +- src/main/git/status.ts | 166 ++- src/main/gitea/client.test.ts | 60 + src/main/gitea/client.ts | 3 +- src/main/gitea/pull-request-scan-cache.ts | 105 +- src/main/gitea/repository-ref.ts | 33 +- .../client-issue-origin-preference.test.ts | 181 +++ src/main/github/client-issue-source.test.ts | 100 +- src/main/github/client.ts | 283 +++-- src/main/github/conflict-summary.ts | 31 +- .../github/github-enterprise-repository.ts | 49 +- .../github/github-owner-repo-selection.ts | 91 ++ .../github-remote-identity-parsing.test.ts | 83 +- .../github/github-remote-identity-parsing.ts | 103 +- ...repository-identity.ssh-host-alias.test.ts | 358 ++++++ .../github-ssh-host-alias-resolution.ts | 192 +++ src/main/github/local-git-config-signature.ts | 73 +- src/main/github/pr-refresh-coordinator.ts | 132 +- .../github/pr-refresh-memory-bounds.test.ts | 93 ++ src/main/github/pr-refresh-memory-bounds.ts | 75 ++ src/main/github/pr-start-point.test.ts | 26 +- src/main/github/pr-start-point.ts | 15 +- src/main/github/project-view/internals.ts | 10 +- src/main/github/review-head-remote.ts | 15 +- .../work-item-details-enterprise-host.test.ts | 8 +- src/main/github/work-item-details.test.ts | 37 +- src/main/github/work-item-details.ts | 7 +- src/main/gitlab/client.ts | 16 +- src/main/gitlab/gitlab-known-host-probe.ts | 179 ++- .../gitlab/gitlab-project-ref-resolution.ts | 52 +- src/main/gitlab/gl-utils.test.ts | 157 ++- src/main/gitlab/gl-utils.ts | 10 + src/main/gitlab/merge-request-creation.ts | 40 +- src/main/hermes/hook-service.ts | 162 ++- src/main/hooks.test.ts | 14 + src/main/hooks.ts | 19 +- src/main/index.ts | 14 - ...gration-account-persistence-limits.test.ts | 47 + .../integration-account-persistence-limits.ts | 53 + src/main/integration-api-concurrency.test.ts | 33 + src/main/integration-api-concurrency.ts | 34 + src/main/integration-credential-file.test.ts | 46 + src/main/integration-credential-file.ts | 20 + src/main/integration-error-message.ts | 13 + src/main/integration-fanout.test.ts | 99 ++ src/main/integration-fanout.ts | 82 ++ .../integration-pagination-budget.test.ts | 62 + src/main/integration-pagination-budget.ts | 96 ++ src/main/ipc/ai-vault-host-scan-batches.ts | 20 + src/main/ipc/ai-vault.test.ts | 57 +- src/main/ipc/ai-vault.ts | 47 +- src/main/ipc/app.test.ts | 49 +- src/main/ipc/app.ts | 14 +- src/main/ipc/custom-pet-file-reader.test.ts | 77 ++ src/main/ipc/custom-pet-file-reader.ts | 59 + src/main/ipc/emulator-frame-stream.ts | 131 +- .../emulator-stream-listener-cleanup.test.ts | 5 + .../ipc/emulator-video-frame-delivery.test.ts | 100 ++ src/main/ipc/emulator-video-frame-delivery.ts | 148 +++ .../emulator-video-stream-admission.test.ts | 243 ++++ src/main/ipc/emulator-video-stream.ts | 110 +- src/main/ipc/export.ts | 8 +- .../ipc/filesystem-directory-reader.test.ts | 65 + src/main/ipc/filesystem-directory-reader.ts | 41 + .../filesystem-external-import-limits.test.ts | 147 +++ .../ipc/filesystem-external-import-limits.ts | 157 +++ .../ipc/filesystem-import-ssh-directory.ts | 142 ++- .../ipc/filesystem-import-ssh-ops.test.ts | 19 +- .../filesystem-import-ssh-path-safety.test.ts | 66 +- src/main/ipc/filesystem-import-ssh.test.ts | 7 +- src/main/ipc/filesystem-import.test.ts | 158 ++- ...list-files-git-directory-expansion.test.ts | 10 +- .../ipc/filesystem-list-files-git-fallback.ts | 107 +- src/main/ipc/filesystem-list-files.test.ts | 165 ++- src/main/ipc/filesystem-list-files.ts | 106 +- src/main/ipc/filesystem-mutations.test.ts | 3 +- src/main/ipc/filesystem-mutations.ts | 228 ++-- ...esystem-runtime-upload-file-reader.test.ts | 44 + .../filesystem-runtime-upload-file-reader.ts | 33 + src/main/ipc/filesystem-search-git.ts | 20 +- .../ipc/filesystem-watcher-admission.test.ts | 62 + src/main/ipc/filesystem-watcher-admission.ts | 132 ++ .../filesystem-watcher-dormant-rearm.test.ts | 169 --- .../filesystem-watcher-large-batch.test.ts | 3 +- ...lesystem-watcher-local-unsubscribe.test.ts | 23 +- ...filesystem-watcher-native-capacity.test.ts | 3 +- ...system-watcher-remote-cancellation.test.ts | 3 +- .../filesystem-watcher-remote-rearm.test.ts | 88 -- ...lesystem-watcher-unwatchable-roots.test.ts | 3 +- src/main/ipc/filesystem-watcher-wsl.test.ts | 11 + src/main/ipc/filesystem-watcher-wsl.ts | 132 +- src/main/ipc/filesystem-watcher.test.ts | 332 ++--- src/main/ipc/filesystem-watcher.ts | 468 ++----- src/main/ipc/filesystem.test.ts | 349 +++++- src/main/ipc/filesystem.ts | 129 +- src/main/ipc/github.ts | 4 + .../ipc/local-log-tail-operation-admission.ts | 88 ++ src/main/ipc/local-log-tail.test.ts | 251 +++- src/main/ipc/local-log-tail.ts | 245 +++- src/main/ipc/markdown-documents.test.ts | 55 +- src/main/ipc/markdown-documents.ts | 83 +- src/main/ipc/native-chat-ipc-admission.ts | 113 ++ .../native-chat-subscribe-lifecycle.test.ts | 196 ++- .../ipc/native-chat-subscription-registry.ts | 178 +++ src/main/ipc/native-chat.test.ts | 12 +- src/main/ipc/native-chat.ts | 207 ++-- src/main/ipc/native-notification-retention.ts | 104 ++ src/main/ipc/notebook.test.ts | 19 + src/main/ipc/notebook.ts | 46 +- .../ipc/notification-ipc-admission.test.ts | 49 + src/main/ipc/notification-ipc-admission.ts | 122 ++ src/main/ipc/notifications.test.ts | 148 ++- src/main/ipc/notifications.ts | 190 +-- src/main/ipc/pet-bundle.test.ts | 35 +- src/main/ipc/pet-bundle.ts | 61 - src/main/ipc/pet.test.ts | 84 +- src/main/ipc/pet.ts | 97 +- .../pty-external-renderer-delivery.test.ts | 244 ++++ .../ipc/pty-pending-data-drain-contract.ts | 22 + ...ding-data-drain-queue-differential.test.ts | 412 +++++++ .../ipc/pty-pending-data-drain-queue.test.ts | 426 +++++++ src/main/ipc/pty-pending-data-drain-queue.ts | 335 +++++ ...-data-drain-scheduler-differential.test.ts | 756 ++++++++++++ .../ipc/pty-renderer-delivery-credit.test.ts | 180 +++ src/main/ipc/pty-renderer-delivery-credit.ts | 297 +++++ .../pty-renderer-delivery-retention.test.ts | 119 ++ .../ipc/pty-renderer-delivery-retention.ts | 188 +++ src/main/ipc/pty-renderer-delivery-router.ts | 35 + src/main/ipc/pty.test.ts | 1015 ++++++++++++++- src/main/ipc/pty.ts | 1094 ++++++++++++----- .../ipc/repo-target-directory-emptiness.ts | 12 + .../ipc/repos-add-linked-worktree.test.ts | 193 +++ src/main/ipc/repos-create.test.ts | 24 +- src/main/ipc/repos.ts | 39 +- .../ipc/runtime-environment-call-queue.ts | 5 +- ...time-environment-shared-control-support.ts | 81 ++ ...environment-subscription-admission.test.ts | 64 + ...time-environment-subscription-admission.ts | 69 ++ ...ntime-environment-subscription-handlers.ts | 238 ++++ ...e-environment-transport-generation.test.ts | 54 + ...untime-environment-transport-generation.ts | 73 +- .../runtime-environment-transport-routing.ts | 188 ++- src/main/ipc/runtime-environments.test.ts | 50 + src/main/ipc/runtime-environments.ts | 191 +-- src/main/ipc/shell-repo-icon-picker.ts | 52 + src/main/ipc/shell.test.ts | 41 + src/main/ipc/shell.ts | 41 +- .../source-control-ai-linked-issue.test.ts | 173 +++ .../ipc/source-control-ai-linked-issue.ts | 83 ++ src/main/ipc/speech-ipc-admission.ts | 152 +++ src/main/ipc/speech.test.ts | 182 ++- src/main/ipc/speech.ts | 102 +- src/main/ipc/ssh-browse.test.ts | 68 +- src/main/ipc/ssh-browse.ts | 45 +- src/main/ipc/ssh-passphrase.test.ts | 179 +++ src/main/ipc/ssh-passphrase.ts | 63 +- src/main/ipc/ssh.test.ts | 233 +--- src/main/ipc/ssh.ts | 201 +-- .../ipc/terminal-preview-output-stream.ts | 22 +- src/main/ipc/terminal-preview.test.ts | 49 +- src/main/ipc/terminal-preview.ts | 44 +- src/main/ipc/workspace-cleanup-activity.ts | 8 +- src/main/ipc/worktree-apfs-clone.ts | 45 +- .../ipc/worktree-base-directory-poller.ts | 129 +- .../ipc/worktree-base-directory-snapshot.ts | 109 ++ ...ase-directory-watch-targets-bounds.test.ts | 81 ++ .../worktree-base-directory-watch-targets.ts | 20 +- .../worktree-base-directory-watcher.test.ts | 1 + .../ipc/worktree-base-directory-watcher.ts | 10 +- .../ipc/worktree-common-git-directory.test.ts | 58 + src/main/ipc/worktree-common-git-directory.ts | 15 +- src/main/ipc/worktree-git-common-polling.ts | 266 +--- src/main/ipc/worktree-git-common-snapshot.ts | 258 ++++ .../ipc/worktree-git-common-watch.test.ts | 137 ++- src/main/ipc/worktree-git-common-watch.ts | 80 +- .../ipc/worktree-head-identity-reader.test.ts | 37 +- src/main/ipc/worktree-head-identity-reader.ts | 123 +- .../ipc/worktree-include-copy-budget.test.ts | 454 +++++++ src/main/ipc/worktree-include-copy-budget.ts | 232 ++++ .../ipc/worktree-polling-scan-budget.test.ts | 287 +++++ src/main/ipc/worktree-polling-scan-budget.ts | 128 ++ src/main/ipc/worktree-remote.ts | 28 +- ...rktree-symlink-reconciliation.real.test.ts | 98 -- src/main/ipc/worktree-symlinks.ts | 130 +- src/main/ipc/worktrees.test.ts | 36 +- src/main/ipc/worktrees.ts | 6 +- src/main/ipc/wsl-watcher-snapshot.test.ts | 27 + src/main/ipc/wsl-watcher-snapshot.ts | 72 ++ src/main/jira/client.test.ts | 73 ++ src/main/jira/client.ts | 257 +++- src/main/jira/issues.ts | 145 ++- src/main/kimi/hook-service.ts | 30 +- src/main/lib/fetch-response-body.test.ts | 105 ++ src/main/lib/fetch-response-body.ts | 1 + src/main/lib/html-to-pdf-memory-limit.test.ts | 17 + src/main/lib/html-to-pdf-memory-limit.ts | 1 + src/main/lib/html-to-pdf.test.ts | 90 ++ src/main/lib/html-to-pdf.ts | 57 +- src/main/linear/client.test.ts | 57 + src/main/linear/client.ts | 303 ++++- src/main/linear/issue-context-fanout-reads.ts | 96 ++ src/main/linear/issue-context-fanout.ts | 6 +- .../linear/issue-context-pagination.test.ts | 41 + src/main/linear/issue-context-pagination.ts | 16 +- .../linear-project-request-coalescer.ts | 33 + src/main/linear/linear-team-pages.test.ts | 66 + src/main/linear/linear-team-pages.ts | 40 +- src/main/linear/projects.ts | 142 ++- ...downloaded-folder-promotion-budget.test.ts | 51 + ...ocal-downloaded-folder-promotion-budget.ts | 49 + ...l-downloaded-folder-promotion-traversal.ts | 51 + .../local-downloaded-folder-promotion.test.ts | 25 + src/main/local-downloaded-folder-promotion.ts | 29 +- src/main/local-worktree-filesystem.test.ts | 30 +- src/main/local-worktree-filesystem.ts | 39 +- src/main/memory/collector.test.ts | 47 + src/main/memory/collector.ts | 37 +- src/main/mimo/hook-service.test.ts | 54 +- src/main/mimo/hook-service.ts | 65 +- src/main/native-chat/session-file-resolver.ts | 11 +- .../transcript-incremental-reader.test.ts | 185 +++ .../transcript-incremental-reader.ts | 105 +- .../transcript-message-retention.test.ts | 44 + .../transcript-message-retention.ts | 59 + .../transcript-read-admission.test.ts | 80 ++ .../native-chat/transcript-read-admission.ts | 144 +++ src/main/native-chat/transcript-read-cache.ts | 11 +- .../native-chat/transcript-reader.test.ts | 39 + src/main/native-chat/transcript-reader.ts | 8 +- .../transcript-record-buffer.test.ts | 33 + .../native-chat/transcript-record-buffer.ts | 44 + .../transcript-stream-lines.test.ts | 50 + .../native-chat/transcript-stream-lines.ts | 45 +- .../native-chat/transcript-tail-reader.ts | 22 +- .../native-chat/transcript-watch-engine.ts | 25 +- .../macos-system-resolver-health.test.ts | 39 + .../network/macos-system-resolver-health.ts | 37 +- src/main/observability/bundle.test.ts | 19 +- src/main/observability/bundle.ts | 10 +- src/main/opencode-usage/scanner.ts | 266 ++-- .../sqlite-usage-row-stream.test.ts | 118 ++ .../opencode-usage/sqlite-usage-row-stream.ts | 238 ++++ .../opencode/config-overlay-manifest.test.ts | 71 ++ src/main/opencode/config-overlay-manifest.ts | 164 +++ src/main/opencode/hook-service.test.ts | 86 ++ src/main/opencode/hook-service.ts | 138 +-- src/main/opencode/opencode-database-files.ts | 40 + src/main/persistence.test.ts | 168 +++ src/main/persistence.ts | 27 +- .../pi/legacy-omp-overlay-migration-budget.ts | 98 ++ src/main/pi/legacy-omp-overlay-migration.ts | 247 ++-- .../pi/managed-extension-ownership.test.ts | 52 + src/main/pi/managed-extension-ownership.ts | 21 + src/main/pi/titlebar-extension-service.ts | 29 +- .../local-workspace-port-scanner.test.ts | 13 + .../ports/local-workspace-port-scanner.ts | 90 +- .../filesystem-path-listing-provider.ts | 7 + src/main/providers/local-pty-provider.ts | 9 +- src/main/providers/pty-process-inspection.ts | 1 + src/main/providers/pty-provider-events.ts | 7 + .../ssh-agent-session-process-list.ts | 139 ++- .../ssh-filesystem-directory-reader.ts | 59 + .../providers/ssh-filesystem-dispatch.test.ts | 69 -- src/main/providers/ssh-filesystem-dispatch.ts | 21 - .../providers/ssh-filesystem-download.test.ts | 135 +- src/main/providers/ssh-filesystem-download.ts | 45 +- ...lesystem-provider-directory-limits.test.ts | 80 ++ ...ilesystem-provider-download-folder.test.ts | 49 +- .../providers/ssh-filesystem-provider-sftp.ts | 31 +- .../ssh-filesystem-provider-stream.test.ts | 50 +- .../providers/ssh-filesystem-provider.test.ts | 14 - src/main/providers/ssh-filesystem-provider.ts | 13 +- .../ssh-markdown-document-listing.test.ts | 42 + .../ssh-markdown-document-listing.ts | 27 + .../providers/ssh-pty-live-roster.test.ts | 134 ++ src/main/providers/ssh-pty-live-roster.ts | 155 +++ .../providers/ssh-pty-notification-routing.ts | 56 - .../ssh-pty-output-delivery-credit.test.ts | 281 +++++ .../ssh-pty-output-delivery-credit.ts | 280 +++++ .../providers/ssh-pty-provider-contract.ts | 9 +- .../ssh-pty-provider-exit-race.test.ts | 31 + .../ssh-pty-provider-notifications.ts | 124 ++ .../ssh-pty-provider-process-events.test.ts | 241 ++++ src/main/providers/ssh-pty-provider.test.ts | 63 + src/main/providers/ssh-pty-provider.ts | 80 +- .../providers/ssh-pty-spawn-exit-race.test.ts | 106 ++ src/main/providers/ssh-pty-spawn-exit-race.ts | 50 +- src/main/providers/ssh-pty-wire-admission.ts | 29 + src/main/providers/types.ts | 12 +- src/main/pty-descendant-termination.test.ts | 8 +- src/main/pty-descendant-termination.ts | 10 +- src/main/pty/config-overlay-mirroring.test.ts | 97 ++ src/main/pty/config-overlay-mirroring.ts | 228 ++++ src/main/pty/overlay-mirror.test.ts | 51 +- src/main/pty/overlay-mirror.ts | 133 +- src/main/pty/shell-startup-env-memory.test.ts | 85 ++ src/main/pty/shell-startup-env.test.ts | 10 + src/main/pty/shell-startup-env.ts | 56 +- .../auth-filesystem-operation.test.ts | 169 ++- .../rate-limits/auth-filesystem-operation.ts | 102 +- src/main/rate-limits/claude-fetcher.test.ts | 35 +- src/main/rate-limits/claude-fetcher.ts | 14 +- .../rate-limits/claude-oauth-usage-error.ts | 4 +- .../rate-limits/claude-pty-reset-parser.ts | 65 +- src/main/rate-limits/claude-pty.ts | 10 +- .../rate-limits/codex-fetcher-backend.test.ts | 67 +- .../codex-fetcher-pty-settle.test.ts | 225 +--- src/main/rate-limits/codex-fetcher.test.ts | 6 +- src/main/rate-limits/codex-fetcher.ts | 242 ++-- .../rate-limits/gemini-cli-oauth-extractor.ts | 129 +- src/main/rate-limits/gemini-oauth-sources.ts | 14 +- .../gemini-usage-fetcher.fallback.test.ts | 4 +- .../rate-limits/gemini-usage-fetcher.test.ts | 4 +- src/main/rate-limits/gemini-usage-fetcher.ts | 3 +- src/main/rate-limits/grok-auth.test.ts | 173 ++- src/main/rate-limits/grok-auth.ts | 5 +- src/main/rate-limits/grok-fetcher.test.ts | 7 +- src/main/rate-limits/grok-fetcher.ts | 3 +- .../rate-limits/hidden-pty-cleanup.test.ts | 27 + src/main/rate-limits/hidden-pty-cleanup.ts | 15 + src/main/rate-limits/kimi-fetcher.test.ts | 11 +- src/main/rate-limits/kimi-fetcher.ts | 8 +- src/main/rate-limits/minimax-fetcher.ts | 3 +- .../rate-limits/opencode-go-usage-fetcher.ts | 5 +- .../rate-limit-pty-output-tail.test.ts | 36 + .../rate-limits/rate-limit-pty-output-tail.ts | 33 + src/main/remote-agent-trust-presets.test.ts | 22 + src/main/remote-agent-trust-presets.ts | 39 +- ...epo-git-remote-identity-enrichment.test.ts | 115 +- .../repo-git-remote-identity-enrichment.ts | 91 +- src/main/repo-git-remote-identity.test.ts | 69 ++ src/main/repo-git-remote-identity.ts | 38 +- src/main/repo-git-username-enrichment.test.ts | 34 + src/main/repo-git-username-enrichment.ts | 27 +- src/main/repo-icon-autodetect.test.ts | 26 +- src/main/repo-icon-autodetect.ts | 207 +--- src/main/repo-icon-file-detection.ts | 190 +++ src/main/repo-location-cache-key.ts | 23 + ...lient-session-tab-selection-persistence.ts | 151 ++- .../client-session-tab-selection-retention.ts | 64 + .../runtime/client-session-tab-selection.ts | 80 +- src/main/runtime/device-registry.ts | 74 +- .../orca-runtime-files-ssh-rearm.test.ts | 145 --- src/main/runtime/orca-runtime-files.test.ts | 160 ++- src/main/runtime/orca-runtime-files.ts | 299 +++-- src/main/runtime/orca-runtime-git.ts | 20 + ...linked-issue-live-meta.integration.test.ts | 141 +++ src/main/runtime/orca-runtime.test.ts | 402 +++--- src/main/runtime/orca-runtime.ts | 353 +++--- ...hestration-message-waiter-registry.test.ts | 235 ++++ .../orchestration-message-waiter-registry.ts | 235 ++++ src/main/runtime/orchestration/coordinator.ts | 134 +- src/main/runtime/orchestration/db.ts | 741 ++++++++--- .../runtime/orchestration/query-retention.ts | 124 ++ .../runtime/recent-pty-output-buffer.test.ts | 28 + src/main/runtime/recent-pty-output-buffer.ts | 257 ++-- .../relay/relay-control-client.test.ts | 88 +- .../runtime/relay/relay-control-client.ts | 44 +- .../relay/relay-control-requests.test.ts | 72 ++ .../runtime/relay/relay-control-requests.ts | 22 +- .../relay/relay-host-proof-bounds.test.ts | 63 + src/main/runtime/relay/relay-host-proof.ts | 18 +- .../runtime/relay/relay-http-client.test.ts | 73 +- src/main/runtime/relay/relay-http-client.ts | 16 +- .../runtime/relay/relay-revoke-outbox.test.ts | 76 +- src/main/runtime/relay/relay-revoke-outbox.ts | 54 +- .../rpc/dispatcher-output-bounds.test.ts | 100 ++ src/main/runtime/rpc/dispatcher.ts | 41 +- src/main/runtime/rpc/errors.test.ts | 19 + src/main/runtime/rpc/errors.ts | 4 +- .../runtime/rpc/methods/agent-session.test.ts | 54 +- src/main/runtime/rpc/methods/agent-session.ts | 35 +- .../runtime/rpc/methods/client-ui-schemas.ts | 1 + .../methods/clipboard-image-upload-buffer.ts | 121 ++ .../runtime/rpc/methods/clipboard.test.ts | 160 ++- src/main/runtime/rpc/methods/clipboard.ts | 96 +- .../rpc/methods/orchestration-gates.ts | 18 +- .../orchestration-query-retention.test.ts | 185 +++ src/main/runtime/rpc/methods/orchestration.ts | 178 ++- src/main/runtime/rpc/methods/terminal.ts | 10 +- .../runtime/rpc/methods/worktree-schemas.ts | 5 +- .../rpc/runtime-rpc-response-serialization.ts | 45 + .../runtime/rpc/unix-socket-transport.test.ts | 24 +- src/main/runtime/rpc/unix-socket-transport.ts | 19 +- .../rpc/ws-fallback-port-store.test.ts | 17 +- .../runtime/rpc/ws-fallback-port-store.ts | 9 +- .../runtime-file-watcher-admission.test.ts | 33 + .../runtime/runtime-file-watcher-admission.ts | 53 + .../runtime-mobile-file-path-search.test.ts | 17 + .../runtime-mobile-file-path-search.ts | 3 + .../runtime-operation-generations.test.ts | 75 ++ .../runtime/runtime-operation-generations.ts | 122 ++ src/main/runtime/runtime-rpc.test.ts | 323 +++-- src/main/runtime/runtime-rpc.ts | 99 +- ...ime-ssh-relay-recovery-generations.test.ts | 114 ++ .../runtime-ssh-relay-recovery-generations.ts | 108 ++ ...time-terminal-file-grant-retention.test.ts | 18 + .../runtime-terminal-file-grant-retention.ts | 22 + .../runtime-text-search-admission.test.ts | 14 + .../runtime/runtime-text-search-admission.ts | 11 + src/main/serve-update-handoff.test.ts | 36 +- src/main/serve-update-handoff.ts | 27 +- src/main/setup-script-import-file.test.ts | 56 + src/main/setup-script-import-file.ts | 18 + .../claude-plugin-skill-sources-wsl.test.ts | 4 + .../skills/claude-plugin-skill-sources-wsl.ts | 29 +- src/main/skills/discovery.test.ts | 21 + src/main/skills/discovery.ts | 222 ++-- .../skills/skill-discovery-limits.test.ts | 67 + src/main/skills/skill-discovery-limits.ts | 119 ++ src/main/skills/skill-discovery-wsl.test.ts | 17 + src/main/skills/skill-discovery-wsl.ts | 76 +- src/main/skills/wsl-skill-protocol-fields.ts | 20 + .../hosted-review-api-request.ts | 9 +- .../pull-request-template.test.ts | 48 + .../source-control/pull-request-template.ts | 42 +- .../repository-ref-cache.test.ts | 61 + .../source-control/repository-ref-cache.ts | 71 ++ src/main/speech/model-cache-path.ts | 100 +- src/main/speech/model-manager.test.ts | 56 + src/main/speech/model-manager.ts | 33 +- .../openai-transcription-client.test.ts | 17 +- .../speech/openai-transcription-client.ts | 51 +- .../speech-model-directory-scanner.test.ts | 172 +++ .../speech/speech-model-directory-scanner.ts | 130 ++ .../speech-model-extraction-stderr.test.ts | 53 + .../speech/speech-model-extraction-stderr.ts | 74 ++ src/main/speech/stt-worker-model-config.ts | 14 +- src/main/ssh/relay-protocol.test.ts | 114 +- src/main/ssh/relay-protocol.ts | 96 +- src/main/ssh/sftp-directory-removal.ts | 116 ++ .../ssh/sftp-namespace-resolution.test.ts | 361 ++++++ src/main/ssh/sftp-namespace-resolution.ts | 245 ++++ src/main/ssh/sftp-upload.test.ts | 37 +- src/main/ssh/sftp-upload.ts | 174 +-- .../ssh/ssh-agent-identity-filter.test.ts | 22 +- src/main/ssh/ssh-agent-identity-filter.ts | 4 +- src/main/ssh/ssh-auth-resolution.ts | 7 +- src/main/ssh/ssh-channel-multiplexer.test.ts | 19 + src/main/ssh/ssh-channel-multiplexer.ts | 4 +- src/main/ssh/ssh-config-bounded-glob.test.ts | 75 ++ src/main/ssh/ssh-config-bounded-glob.ts | 124 ++ src/main/ssh/ssh-config-expansion-budget.ts | 153 +++ src/main/ssh/ssh-config-include-expander.ts | 121 +- ...sh-config-include-expansion-bounds.test.ts | 82 ++ .../ssh/ssh-config-loader-regression.test.ts | 47 +- .../ssh/ssh-connection-generation.test.ts | 186 ++- src/main/ssh/ssh-connection-generation.ts | 195 ++- .../ssh/ssh-connection-sftp-namespace.test.ts | 471 +++++++ src/main/ssh/ssh-connection-sftp-wire.test.ts | 361 ++++++ src/main/ssh/ssh-connection-utils.test.ts | 36 +- src/main/ssh/ssh-connection-utils.ts | 8 +- src/main/ssh/ssh-connection.test.ts | 233 +--- src/main/ssh/ssh-connection.ts | 127 +- .../ssh/ssh-directory-transfer-budget.test.ts | 51 + src/main/ssh/ssh-directory-transfer-budget.ts | 64 + src/main/ssh/ssh-file-stream-assembler.ts | 196 +++ src/main/ssh/ssh-filesystem-stream-reader.ts | 243 ++-- .../ssh/ssh-git-response-stream-assembler.ts | 108 ++ .../ssh/ssh-git-response-stream-reader.ts | 200 +-- src/main/ssh/ssh-key-file.test.ts | 37 + src/main/ssh/ssh-key-file.ts | 7 + src/main/ssh/ssh-port-forward.test.ts | 39 +- src/main/ssh/ssh-port-forward.ts | 13 +- src/main/ssh/ssh-port-scanner.test.ts | 24 + src/main/ssh/ssh-port-scanner.ts | 7 +- .../ssh-relay-base-directory-listing.test.ts | 121 ++ .../ssh/ssh-relay-base-directory-listing.ts | 93 ++ .../ssh-relay-bounded-marker-commands.test.ts | 26 + .../ssh/ssh-relay-bounded-marker-commands.ts | 23 + .../ssh-relay-cross-version-isolation.test.ts | 47 +- src/main/ssh/ssh-relay-deploy-helpers.test.ts | 102 +- src/main/ssh/ssh-relay-deploy-helpers.ts | 57 +- src/main/ssh/ssh-relay-deploy.test.ts | 15 +- src/main/ssh/ssh-relay-deploy.ts | 194 +-- src/main/ssh/ssh-relay-exec-command.ts | 36 +- src/main/ssh/ssh-relay-gc-claim.ts | 18 +- .../ssh/ssh-relay-install-lock-commands.ts | 5 +- src/main/ssh/ssh-relay-install-marker.ts | 29 + .../ssh/ssh-relay-install-namespace.test.ts | 151 +++ src/main/ssh/ssh-relay-install-namespace.ts | 122 ++ src/main/ssh/ssh-relay-install-transfers.ts | 101 ++ .../ssh-relay-liveness-probe-source.test.ts | 156 +++ .../ssh/ssh-relay-liveness-probe-source.ts | 91 ++ .../ssh/ssh-relay-native-deps-install.test.ts | 60 +- .../ssh-relay-session-data-delivery.test.ts | 27 +- .../ssh/ssh-relay-session-incarnation.test.ts | 18 +- ...elay-session-reconnect-incarnation.test.ts | 18 +- src/main/ssh/ssh-relay-session.test.ts | 118 +- src/main/ssh/ssh-relay-session.ts | 62 +- .../ssh-relay-sftp-namespace-install.test.ts | 599 +++++++++ .../ssh/ssh-relay-versioned-install.test.ts | 30 +- src/main/ssh/ssh-relay-versioned-install.ts | 13 +- .../ssh/ssh-remote-cli-host-passthrough.ts | 14 +- src/main/ssh/ssh-remote-commands.test.ts | 9 + src/main/ssh/ssh-remote-commands.ts | 44 +- .../ssh-remote-node-candidate-probe.test.ts | 91 ++ .../ssh/ssh-remote-node-candidate-probe.ts | 133 ++ .../ssh/ssh-remote-node-resolution.test.ts | 40 +- src/main/ssh/ssh-remote-node-resolution.ts | 53 +- src/main/ssh/ssh-remote-orca-cli.test.ts | 8 + src/main/ssh/ssh-stream-reader-memory.test.ts | 432 +++++++ src/main/ssh/ssh-stream-reader-memory.ts | 205 +++ .../ssh/ssh-windows-log-tail-command.test.ts | 28 + src/main/ssh/ssh-windows-log-tail-command.ts | 31 + src/main/ssh/system-ssh-file-transfer.ts | 82 +- .../ssh/system-ssh-forward-process.test.ts | 17 + src/main/ssh/system-ssh-forward-process.ts | 7 +- .../system-ssh-operation-lifecycle.test.ts | 50 + .../ssh/system-ssh-operation-lifecycle.ts | 15 +- src/main/ssh/system-ssh-output-tail.test.ts | 30 + src/main/ssh/system-ssh-output-tail.ts | 37 + .../ssh/system-ssh-port-forward-provider.ts | 15 +- src/main/startup/configure-process.test.ts | 34 +- src/main/startup/configure-process.ts | 14 +- .../startup/ensure-virtual-display.test.ts | 14 +- src/main/startup/ensure-virtual-display.ts | 25 +- src/main/startup/hydrate-shell-path.test.ts | 40 + src/main/startup/hydrate-shell-path.ts | 16 +- src/main/stats/collector.ts | 169 ++- src/main/stats/stats-aggregate-tracker.ts | 71 ++ .../stats/stats-file-json-admission.test.ts | 22 + src/main/stats/stats-retention.ts | 170 +++ src/main/terminal-history-gc.test.ts | 213 ++++ src/main/terminal-history-gc.ts | 305 +++++ src/main/terminal-history.test.ts | 41 +- src/main/terminal-history.ts | 128 +- .../commit-message-text-generation.test.ts | 514 +++++++- .../commit-message-text-generation.ts | 198 ++- .../local-ai-process-budget.ts | 110 ++ src/main/updater-changelog.test.ts | 13 + src/main/updater-changelog.ts | 3 +- src/main/updater-nudge.ts | 3 +- src/main/updater-prerelease-feed.ts | 5 +- src/main/usage-history-file-discovery.ts | 44 + src/main/usage-history-jsonl-reader.test.ts | 63 + src/main/usage-history-jsonl-reader.ts | 57 + src/main/usage-history-scan-budget.test.ts | 80 ++ src/main/usage-history-scan-budget.ts | 111 ++ src/main/usage-projection-state-file.test.ts | 96 ++ src/main/usage-projection-state-file.ts | 98 ++ .../attach-main-window-services.test.ts | 14 +- .../window/attach-main-window-services.ts | 2 +- .../window/clipboard-ipc-handlers.test.ts | 11 +- src/main/window/clipboard-remote-file-copy.ts | 48 +- src/main/window/createMainWindow.test.ts | 6 +- .../window/renderer-viewport-reflow.test.ts | 43 +- src/main/window/renderer-viewport-reflow.ts | 18 +- src/main/windows-pty-root-identity.test.ts | 14 +- src/main/windows-pty-root-identity.ts | 17 +- src/main/workspace-space-analysis.test.ts | 88 +- src/main/workspace-space-analysis.ts | 364 ++---- src/main/worktree-orphan-gitdir-proof.ts | 14 +- src/main/worktree-removal-safety.test.ts | 16 + src/main/worktree-removal-safety.ts | 7 +- src/preload/api-types.ts | 17 +- .../emulator-stream-frame-listeners.test.ts | 79 ++ .../emulator-stream-frame-listeners.ts | 60 + src/preload/index.ts | 42 +- .../renderer-heap-statistics-reader.test.ts | 74 ++ .../renderer-heap-statistics-reader.ts | 37 + src/relay/agent-exec-handler.test.ts | 74 +- src/relay/agent-exec-handler.ts | 55 +- src/relay/agent-hook-server.test.ts | 12 +- src/relay/agent-hook-server.ts | 21 +- src/relay/client-request-aborts.test.ts | 91 ++ src/relay/client-request-aborts.ts | 122 +- src/relay/dispatcher.test.ts | 179 ++- src/relay/dispatcher.ts | 149 ++- .../external-automations-handler.test.ts | 180 ++- src/relay/external-automations-handler.ts | 238 ++-- src/relay/filesystem-directory-reader.test.ts | 63 + src/relay/filesystem-directory-reader.ts | 70 ++ src/relay/fs-handler-file-read.ts | 88 +- src/relay/fs-handler-git-fallback.ts | 115 +- .../fs-handler-list-files-cancel.test.ts | 16 +- .../fs-handler-list-files-ignored.test.ts | 116 +- src/relay/fs-handler-list-files.ts | 87 +- src/relay/fs-handler-stream.test.ts | 186 ++- src/relay/fs-handler-terminal-artifact.ts | 10 +- src/relay/fs-handler-utils.ts | 20 +- src/relay/fs-handler.test.ts | 12 +- src/relay/fs-handler.ts | 81 +- .../fs-list-files-cancel.integration.test.ts | 4 +- ...-pty-echo-backpressure.integration.test.ts | 28 +- src/relay/fs-stream-registry.ts | 34 +- .../git-handler-status-ops-bounds.test.ts | 39 + src/relay/git-handler-status-ops.ts | 8 +- ...andler-stream-serialization-memory.test.ts | 40 + src/relay/git-handler-submodule-ops.test.ts | 139 +++ src/relay/git-handler-submodule-ops.ts | 88 +- src/relay/git-handler-utils.ts | 3 +- src/relay/git-handler.test.ts | 108 +- src/relay/git-handler.ts | 140 +-- .../git-response-stream-ownership.test.ts | 126 +- src/relay/git-response-stream.ts | 135 +- src/relay/git-response-utf8-chunks.test.ts | 30 + src/relay/git-response-utf8-chunks.ts | 55 + ...git-status-upstream-negative-cache.test.ts | 54 + .../git-status-upstream-negative-cache.ts | 14 + src/relay/git-working-file-read.test.ts | 5 +- src/relay/git-working-file-read.ts | 18 +- src/relay/integration.test.ts | 17 + src/relay/markdown-document-listing.test.ts | 93 ++ src/relay/markdown-document-listing.ts | 238 ++++ .../mobile-file-directory-reader.test.ts | 61 + src/relay/mobile-file-directory-reader.ts | 76 ++ src/relay/plugin-overlay.test.ts | 27 +- src/relay/plugin-overlay.ts | 113 +- src/relay/port-scan-handler.test.ts | 17 +- src/relay/port-scan-handler.ts | 150 +-- src/relay/preflight-handler.test.ts | 51 +- src/relay/preflight-handler.ts | 10 +- src/relay/protocol-handshake.test.ts | 67 + .../protocol-json-encoding-memory.test.ts | 53 + src/relay/protocol.ts | 94 +- src/relay/pty-handler.test.ts | 153 ++- src/relay/pty-handler.ts | 261 ++-- src/relay/pty-output-broadcast.test.ts | 245 ++++ src/relay/pty-output-broadcast.ts | 300 +++++ src/relay/pty-output-flow-state.ts | 240 ++++ src/relay/pty-persistence-envelope.test.ts | 102 ++ src/relay/pty-persistence-envelope.ts | 243 ++++ src/relay/pty-shell-launch.test.ts | 20 +- src/relay/pty-shell-launch.ts | 8 +- src/relay/pty-shell-utils.test.ts | 131 +- src/relay/pty-shell-utils.ts | 42 +- .../relay-filesystem-watch-registry.test.ts | 16 + src/relay/relay-filesystem-watch-registry.ts | 6 +- src/relay/relay-handshake.ts | 5 +- src/relay/relay-watcher-removal-fence.ts | 17 +- src/relay/relay-watcher-root-capacity.test.ts | 72 ++ src/relay/relay-watcher-root-capacity.ts | 28 +- src/relay/relay-watcher-teardown-tracker.ts | 1 + src/relay/relay.ts | 100 +- src/relay/workspace-session-handler.test.ts | 129 +- src/relay/workspace-session-handler.ts | 103 +- src/relay/workspace-space-scan.ts | 243 +--- src/relay/wsl-hook-fs-bridge.test.ts | 61 +- src/relay/wsl-hook-fs-bridge.ts | 78 +- src/relay/wsl-install-plugins-handler.test.ts | 193 +++ src/relay/wsl-install-plugins-handler.ts | 89 ++ .../components/editor/CombinedDiffViewer.tsx | 89 +- .../src/components/editor/CsvViewer.tsx | 40 +- .../EditorContent.monaco-lifecycle.test.tsx | 23 +- .../src/components/editor/EditorContent.tsx | 2 + .../src/components/editor/IpynbViewer.tsx | 3 +- .../src/components/editor/MonacoEditor.tsx | 25 +- .../components/editor/RichMarkdownEditor.tsx | 10 +- .../editor/base64-byte-decoder.test.ts | 13 + .../components/editor/base64-byte-decoder.ts | 18 + .../combined-diff-load-scheduler.test.ts | 64 + .../editor/combined-diff-load-scheduler.ts | 45 +- .../combined-diff-text-retention.test.ts | 128 ++ .../editor/combined-diff-text-retention.ts | 205 +++ .../src/components/editor/csv-parse.test.ts | 86 +- .../src/components/editor/csv-parse.ts | 112 +- .../components/editor/diff-section-types.ts | 2 + .../editor/ipynb-json-admission.test.ts | 52 + .../components/editor/ipynb-json-admission.ts | 94 ++ .../src/components/editor/ipynb-parse.test.ts | 13 + .../src/components/editor/ipynb-parse.ts | 39 +- .../editor/local-image-blob-retention.test.ts | 17 + .../editor/local-image-blob-retention.ts | 61 + .../editor/local-image-load-admission.test.ts | 61 + .../editor/local-image-load-admission.ts | 62 + .../editor/markdown-doc-links.test.ts | 4 + .../components/editor/markdown-doc-links.ts | 10 +- .../markdown-document-list-request.test.ts | 27 + .../editor/markdown-document-list-request.ts | 17 +- ...rkdown-document-worktree-retention.test.ts | 57 + .../markdown-document-worktree-retention.ts | 41 + .../editor/markdown-export-extract.test.ts | 37 +- .../editor/markdown-export-extract.ts | 87 +- .../monaco-markdown-doc-completions.test.ts | 68 + .../editor/monaco-markdown-doc-completions.ts | 109 +- .../pending-editor-focus-request.test.ts | 47 + .../editor/pending-editor-focus-request.ts | 20 + .../editor/rich-markdown-auto-focus.ts | 14 +- .../editor/rich-markdown-editor-props.ts | 1 + .../editor/rich-markdown-local-image.test.ts | 2 +- .../editor/useLocalImageSrc.test.ts | 48 +- .../src/components/editor/useLocalImageSrc.ts | 140 ++- .../editor/useMarkdownDocuments.test.tsx | 96 ++ .../components/editor/useMarkdownDocuments.ts | 34 +- .../useRichMarkdownPendingFocus.test.ts | 163 +++ .../editor/useRichMarkdownPendingFocus.ts | 52 +- ...github-item-dialog-source-boundary.test.ts | 248 ---- .../pull-request-page-host-boundary.test.ts | 316 ----- .../appearance-interface-summary.test.ts | 27 + .../settings/appearance-interface-summary.ts | 35 + .../developer-permissions-search.test.ts | 63 + .../settings/developer-permissions-search.ts | 11 +- .../settings/osc52-clipboard-copy.test.ts | 45 + .../status-bar/WorkspaceSpaceManagerPanel.tsx | 20 +- .../status-bar/hosted-review-label.test.ts | 44 + .../status-bar/hosted-review-label.ts | 25 + .../osc52-clipboard-blocked-toast.test.ts | 24 +- .../osc52-clipboard-blocked-toast.ts | 6 +- .../osc52-clipboard-default-on-notice.test.ts | 166 +++ .../osc52-clipboard-default-on-notice.ts | 68 + .../terminal-pane/osc52-clipboard.test.ts | 273 +++- .../terminal-pane/osc52-clipboard.ts | 137 ++- .../pane-title-overlay-rects.test.ts | 60 + .../terminal-pane/pane-title-overlay-rects.ts | 33 + .../terminal-pane/pty-connection.test.ts | 261 +--- .../terminal-pane/pty-connection.ts | 48 +- .../pty-input-write-queue.test.ts | 38 + .../terminal-pane/pty-input-write-queue.ts | 52 +- .../pty-shutdown-data-suspension.test.ts | 18 + .../pty-shutdown-data-suspension.ts | 4 +- .../terminal-pane/pty-transport-types.ts | 3 - .../terminal-pane/pty-transport.test.ts | 17 - .../components/terminal-pane/pty-transport.ts | 2 - .../remote-runtime-pty-batching.test.ts | 48 + .../remote-runtime-pty-batching.ts | 34 +- .../remote-runtime-pty-transport.test.ts | 16 +- .../remote-runtime-pty-transport.ts | 24 +- ...emote-runtime-viewport-claim-input.test.ts | 22 + .../remote-runtime-viewport-claim-input.ts | 31 + .../terminal-pane/terminal-layout-leaf-ids.ts | 26 + .../terminal-link-handlers.test.ts | 336 +---- .../terminal-pane/terminal-link-handlers.ts | 184 +-- .../terminal-pane-recovery-retirement.test.ts | 31 + .../terminal-pane-recovery-retirement.ts | 18 + .../terminal-pane-recovery.test.ts | 31 + .../terminal-pane/terminal-pane-recovery.ts | 24 + ...rminal-park-verdict-flip-telemetry.test.ts | 173 +++ .../terminal-park-verdict-flip-telemetry.ts | 93 ++ .../use-terminal-pane-lifecycle.ts | 22 +- .../src/hooks/metadata-request-cache.test.ts | 199 ++- .../src/hooks/metadata-request-cache.ts | 207 +++- .../hooks/metadata-retention-measurement.ts | 135 ++ .../runtime-project-refresh-scheduler.test.ts | 54 + .../runtime-project-refresh-scheduler.ts | 98 +- ...known-ssh-target-state-coordinator.test.ts | 148 +++ .../unknown-ssh-target-state-coordinator.ts | 113 ++ src/renderer/src/hooks/useIpcEvents.test.ts | 294 +---- src/renderer/src/hooks/useIpcEvents.ts | 126 +- ...ectionPaste.terminal-native-paste.test.tsx | 146 +++ .../hooks/usePrimarySelectionPaste.test.tsx | 42 +- .../src/hooks/usePrimarySelectionPaste.ts | 7 +- .../worktree-change-refresh-queue.test.ts | 52 + .../hooks/worktree-change-refresh-queue.ts | 61 +- .../worktree-rename-grace-retention.test.ts | 59 + .../hooks/worktree-rename-grace-retention.ts | 133 ++ src/renderer/src/i18n/locales/en.json | 22 +- src/renderer/src/i18n/locales/es.json | 20 +- src/renderer/src/i18n/locales/ja.json | 20 +- src/renderer/src/i18n/locales/ko.json | 20 +- src/renderer/src/i18n/locales/zh.json | 26 +- .../lib/agent-hibernation-coordinator.test.ts | 56 + .../src/lib/agent-hibernation-coordinator.ts | 10 +- .../src/lib/ai-vault-omp-cold-resume.test.ts | 108 -- src/renderer/src/lib/ai-vault-session-drag.ts | 3 +- .../github-work-item-details-cache.test.tsx | 204 +++ .../src/lib/github-work-item-details-cache.ts | 263 ++++ ...github-work-item-details-retained-bytes.ts | 139 +++ .../pane-manager/terminal-scroll-intent.ts | 13 +- .../terminal-write-pipeline-health.test.ts | 44 + .../terminal-write-pipeline-health.ts | 77 +- .../src/lib/primary-selection.test.ts | 40 +- src/renderer/src/lib/primary-selection.ts | 10 +- .../src/lib/react-error-boundary-reporting.ts | 26 +- src/renderer/src/lib/repo-slug-index.ts | 190 ++- .../src/lib/repo-slug-resolution-pool.test.ts | 207 ++++ .../src/lib/repo-slug-resolution-pool.ts | 126 ++ ...sient-agent-status-clear-retention.test.ts | 52 + .../transient-agent-status-clear-retention.ts | 103 ++ ...workspace-session-host-persistence.test.ts | 42 + .../lib/workspace-session-host-persistence.ts | 48 +- ...web-session-snapshot-recovery-pool.test.ts | 52 + .../web-session-snapshot-recovery-pool.ts | 10 + .../src/runtime/web-session-tabs-sync.test.ts | 164 +-- .../src/runtime/web-session-tabs-sync.ts | 50 +- ...terminal-orphan-recovery-admission.test.ts | 76 ++ ...sion-terminal-orphan-recovery-admission.ts | 187 +++ .../web-session-terminal-orphan-recovery.ts | 30 +- .../ssh-startup-reconnect-fanout.test.ts | 64 + .../src/startup/ssh-startup-reconnect.ts | 35 + .../agent-status-provider-session.test.ts | 266 ++-- src/renderer/src/store/slices/agent-status.ts | 43 +- src/renderer/src/store/slices/editor.ts | 40 +- .../repos-shared-project-badge-color.test.ts | 181 +++ src/renderer/src/store/slices/repos.ts | 22 +- .../ssh-credential-request-retention.test.ts | 82 ++ .../ssh-credential-request-retention.ts | 119 ++ src/renderer/src/store/slices/terminals.ts | 8 + src/renderer/src/store/slices/ui.test.ts | 30 + src/renderer/src/store/slices/ui.ts | 11 + src/renderer/src/web/web-e2ee.test.ts | 32 + src/renderer/src/web/web-e2ee.ts | 9 + .../src/web/web-local-storage-json.test.ts | 93 ++ .../src/web/web-local-storage-json.ts | 64 + src/renderer/src/web/web-pairing.test.ts | 26 +- src/renderer/src/web/web-pairing.ts | 25 +- src/renderer/src/web/web-preload-api.test.ts | 169 +++ src/renderer/src/web/web-preload-api.ts | 154 ++- .../web/web-preload-request-owners.test.ts | 55 + .../src/web/web-preload-request-owners.ts | 49 + .../src/web/web-runtime-client.test.ts | 104 +- src/renderer/src/web/web-runtime-client.ts | 748 +++++++++-- .../src/web/web-runtime-environment.ts | 5 +- .../src/web/web-runtime-inbound-json.test.ts | 52 + .../src/web/web-runtime-inbound-json.ts | 24 + .../src/web/web-runtime-outbound-json.test.ts | 93 ++ .../src/web/web-runtime-outbound-json.ts | 161 +++ ...web-runtime-outbound-memory-budget.test.ts | 67 + .../web/web-runtime-outbound-memory-budget.ts | 125 ++ src/shared/commit-message-generation.ts | 2 + src/shared/constants.ts | 11 +- src/shared/custom-pet-media-limits.ts | 19 + ...filesystem-directory-listing-limit.test.ts | 59 + .../filesystem-directory-listing-limit.ts | 75 ++ src/shared/hermes-run-output-limits.test.ts | 165 +++ src/shared/hermes-run-output-limits.ts | 254 ++++ src/shared/hermes-run-ref-retention.test.ts | 36 + src/shared/hermes-run-ref-retention.ts | 78 ++ src/shared/html-to-pdf-memory-limit.ts | 13 + .../linux-proc-port-scan-limits.test.ts | 52 + src/shared/linux-proc-port-scan-limits.ts | 55 + .../mobile-file-directory-limit.test.ts | 37 + src/shared/mobile-file-directory-limit.ts | 43 + src/shared/osc52-clipboard-settings.test.ts | 78 ++ src/shared/osc52-clipboard-settings.ts | 37 + .../persisted-state-file-bounds.test.ts | 171 +++ src/shared/persisted-state-file-bounds.ts | 222 ++++ src/shared/pr-refresh-memory-limits.ts | 5 + src/shared/pull-request-generation.ts | 2 + src/shared/quick-open-listing-limits.test.ts | 82 ++ src/shared/quick-open-listing-limits.ts | 142 +++ src/shared/relay-json-admission.ts | 12 + src/shared/renderer-heap-statistics.ts | 24 + ...source-control-ai-action-variables.test.ts | 94 ++ .../source-control-ai-action-variables.ts | 129 ++ .../ssh-retained-payload-admission.test.ts | 106 ++ src/shared/ssh-retained-payload-admission.ts | 138 +++ src/shared/terminal-size-limits.ts | 34 + src/shared/types.ts | 6 +- .../e2e/codex-composer-echo-latency-probe.ts | 203 +++ tests/e2e/helpers/alt-screen-frame.ts | 76 ++ .../source-control-pr-linked-issue-ai.spec.ts | 96 ++ tests/e2e/ssh-external-image-preview.spec.ts | 197 --- 1272 files changed, 83735 insertions(+), 20788 deletions(-) create mode 100644 config/scripts/check-no-merge-base-revert.mjs create mode 100644 config/scripts/check-no-merge-base-revert.test.mjs create mode 100644 mobile/scripts/bounded-process-line-reader.mjs create mode 100644 mobile/scripts/bounded-response-body.mjs create mode 100644 mobile/src/browser/mobile-browser-frame-cache.test.ts create mode 100644 mobile/src/browser/mobile-browser-frame-cache.ts create mode 100644 mobile/src/cache/mobile-rpc-list-cache.test.ts create mode 100644 mobile/src/cache/mobile-rpc-list-cache.ts create mode 100644 mobile/src/files/mobile-directory-cache-retention.test.ts create mode 100644 mobile/src/files/mobile-directory-cache-retention.ts create mode 100644 mobile/src/hooks/mobile-dictation-audio-chunk.test.ts create mode 100644 mobile/src/session/mobile-image-base64-accumulator.test.ts create mode 100644 mobile/src/session/mobile-image-base64-accumulator.ts create mode 100644 mobile/src/session/mobile-session-file-doc-lifecycle.test.ts create mode 100644 mobile/src/session/mobile-session-file-doc-lifecycle.ts create mode 100644 mobile/src/session/mobile-session-markdown-doc-lifecycle.test.ts create mode 100644 mobile/src/session/mobile-session-markdown-doc-lifecycle.ts create mode 100644 mobile/src/tasks/mobile-pr-file-content-cache.test.ts create mode 100644 mobile/src/tasks/mobile-pr-file-content-cache.ts create mode 100644 mobile/src/tasks/use-mobile-pr-file-content-cache.ts create mode 100644 mobile/src/terminal/terminal-webview-default-theme.ts create mode 100644 mobile/src/transport/bounded-process-line-reader.test.ts create mode 100644 mobile/src/transport/bounded-response-body.test.ts create mode 100644 mobile/src/transport/host-client-open-registry.test.ts create mode 100644 mobile/src/transport/mobile-direct-rpc-outbound.test.ts create mode 100644 mobile/src/transport/mobile-direct-rpc-outbound.ts create mode 100644 mobile/src/transport/mobile-direct-rpc-sender.ts create mode 100644 mobile/src/transport/mobile-host-list-storage.ts create mode 100644 mobile/src/transport/mobile-inbound-frame-queue-memory.test.ts create mode 100644 mobile/src/transport/mobile-inbound-frame-queue.test.ts create mode 100644 mobile/src/transport/mobile-inbound-frame-queue.ts create mode 100644 mobile/src/transport/mobile-inbound-memory-ledger.test.ts create mode 100644 mobile/src/transport/mobile-inbound-memory-ledger.ts create mode 100644 mobile/src/transport/mobile-json-text-admission.test.ts create mode 100644 mobile/src/transport/mobile-json-text-admission.ts create mode 100644 mobile/src/transport/mobile-outbound-json.test.ts create mode 100644 mobile/src/transport/mobile-outbound-json.ts create mode 100644 mobile/src/transport/mobile-outbound-memory-budget.test.ts create mode 100644 mobile/src/transport/mobile-outbound-memory-budget.ts create mode 100644 mobile/src/transport/mobile-outbound-socket-ledger.test.ts create mode 100644 mobile/src/transport/mobile-outbound-socket-ledger.ts create mode 100644 mobile/src/transport/mobile-relay-direct-upgrade-journal.test.ts delete mode 100644 mobile/src/transport/mobile-relay-rpc-streams.test.ts create mode 100644 mobile/src/transport/mobile-rpc-binary-frame-handler.ts create mode 100644 mobile/src/transport/redacted-websocket-endpoint.ts create mode 100644 src/cli/computer-screenshot-storage.test.ts create mode 100644 src/cli/computer-screenshot-storage.ts create mode 100644 src/cli/runtime/recipe-json-output.ts create mode 100644 src/main/agent-hooks/agent-hook-file-comparison.ts create mode 100644 src/main/agent-hooks/agent-hook-file-limits.ts create mode 100644 src/main/agent-hooks/agent-hook-sftp-text-reader.ts create mode 100644 src/main/agent-hooks/wsl-guest-plugin-install.test.ts create mode 100644 src/main/agent-hooks/wsl-guest-plugin-install.ts create mode 100644 src/main/ai-vault/claude-subagent-list-retention.ts create mode 100644 src/main/ai-vault/session-discovery-budget.test.ts create mode 100644 src/main/ai-vault/session-discovery-budget.ts create mode 100644 src/main/ai-vault/session-jsonl-line-reader.test.ts create mode 100644 src/main/ai-vault/session-jsonl-line-reader.ts create mode 100644 src/main/ai-vault/session-list-results.test.ts create mode 100644 src/main/ai-vault/session-list-retention.test.ts create mode 100644 src/main/ai-vault/session-list-retention.ts create mode 100644 src/main/ai-vault/session-parse-cache-payload.test.ts create mode 100644 src/main/ai-vault/session-parse-cache-payload.ts create mode 100644 src/main/ai-vault/session-parse-cache-retention.test.ts create mode 100644 src/main/ai-vault/session-parse-cache-retention.ts delete mode 100644 src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts create mode 100644 src/main/ai-vault/session-scanner-discovery.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-limits.ts create mode 100644 src/main/ai-vault/session-scanner-retention.test.ts create mode 100644 src/main/ai-vault/session-scope-cwd-cache.test.ts create mode 100644 src/main/ai-vault/session-scope-cwd-cache.ts create mode 100644 src/main/ai-vault/session-whole-json-reader.test.ts create mode 100644 src/main/ai-vault/session-whole-json-reader.ts create mode 100644 src/main/automations/external-manager-remote-fanout.test.ts create mode 100644 src/main/browser/browser-cookie-json-file-parser.test.ts create mode 100644 src/main/browser/browser-cookie-json-file-parser.ts create mode 100644 src/main/browser/browser-pdf-admission.test.ts create mode 100644 src/main/browser/browser-pdf-admission.ts create mode 100644 src/main/browser/browser-screenshot-admission.test.ts create mode 100644 src/main/browser/browser-screenshot-admission.ts create mode 100644 src/main/browser/browser-screenshot-file-reader.test.ts create mode 100644 src/main/browser/browser-screenshot-file-reader.ts create mode 100644 src/main/browser/browser-screenshot-limits.test.ts create mode 100644 src/main/browser/browser-screenshot-limits.ts create mode 100644 src/main/browser/cdp-command-queue.test.ts create mode 100644 src/main/browser/cdp-command-queue.ts create mode 100644 src/main/browser/cdp-event-memory-bounds.test.ts create mode 100644 src/main/browser/cdp-event-memory-bounds.ts create mode 100644 src/main/browser/installed-browser-cookie-store-limits.test.ts create mode 100644 src/main/browser/installed-browser-cookie-store-limits.ts create mode 100644 src/main/browser/native-image-screenshot-encoder.ts create mode 100644 src/main/browser/safari-cookie-store-decoder.test.ts create mode 100644 src/main/browser/safari-cookie-store-decoder.ts create mode 100644 src/main/cache-identity-digest.ts create mode 100644 src/main/codex-accounts/codex-wsl-runtime-home-retention.ts create mode 100644 src/main/codex-accounts/legacy-history-migration.test.ts create mode 100644 src/main/codex-accounts/legacy-history-migration.ts create mode 100644 src/main/codex-accounts/legacy-managed-state-migration.test.ts create mode 100644 src/main/codex-accounts/legacy-managed-state-migration.ts create mode 100644 src/main/codex-accounts/legacy-session-migration.test.ts create mode 100644 src/main/codex-accounts/legacy-session-migration.ts create mode 100644 src/main/codex-cli/nvm-version-directory-discovery.test.ts create mode 100644 src/main/codex-cli/nvm-version-directory-discovery.ts create mode 100644 src/main/codex/codex-app-server-grant-json.test.ts create mode 100644 src/main/codex/codex-app-server-grant-json.ts create mode 100644 src/main/codex/codex-session-file-listing.test.ts create mode 100644 src/main/codex/codex-session-index-heal-jsonl.test.ts create mode 100644 src/main/codex/codex-session-index-heal-jsonl.ts create mode 100644 src/main/codex/codex-session-listing-budget.ts create mode 100644 src/main/codex/codex-wsl-reconciliation-generations.ts create mode 100644 src/main/computer/macos-native-provider-transport.test.ts create mode 100644 src/main/daemon/daemon-admission-limits.test.ts create mode 100644 src/main/daemon/daemon-admission-limits.ts create mode 100644 src/main/daemon/daemon-client-outbound-admission.test.ts create mode 100644 src/main/daemon/daemon-control-file-reader.test.ts create mode 100644 src/main/daemon/daemon-control-file-reader.ts create mode 100644 src/main/daemon/daemon-login-session-probe-verdict.test.ts create mode 100644 src/main/daemon/daemon-login-session-probe-verdict.ts create mode 100644 src/main/daemon/daemon-response-admission.ts create mode 100644 src/main/daemon/daemon-server-admission.test.ts delete mode 100644 src/main/daemon/daemon-stream-droppability-lifecycle.test.ts delete mode 100644 src/main/daemon/daemon-stream-droppable-membership.test.ts delete mode 100644 src/main/daemon/daemon-stream-droppable-membership.ts create mode 100644 src/main/daemon/headless-emulator-size-limits.test.ts create mode 100644 src/main/daemon/history-manager-memory.test.ts create mode 100644 src/main/daemon/session-pre-ready-input-memory.test.ts delete mode 100644 src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts create mode 100644 src/main/daemon/terminal-history-log-state.ts create mode 100644 src/main/daemon/terminal-history-session-metadata.ts delete mode 100644 src/main/daemon/terminal-session-teardown.test.ts create mode 100644 src/main/emulator/android/scrcpy-stream-session.test.ts create mode 100644 src/main/emulator/scrcpy-video-registry-memory.test.ts create mode 100644 src/main/filesystem-provider-bounded-text.test.ts create mode 100644 src/main/filesystem-provider-bounded-text.ts create mode 100644 src/main/generated-node-bounded-file-reader.test.ts create mode 100644 src/main/generated-node-bounded-file-reader.ts create mode 100644 src/main/github/client-issue-origin-preference.test.ts create mode 100644 src/main/github/github-owner-repo-selection.ts create mode 100644 src/main/github/github-repository-identity.ssh-host-alias.test.ts create mode 100644 src/main/github/github-ssh-host-alias-resolution.ts create mode 100644 src/main/github/pr-refresh-memory-bounds.test.ts create mode 100644 src/main/github/pr-refresh-memory-bounds.ts create mode 100644 src/main/integration-account-persistence-limits.test.ts create mode 100644 src/main/integration-account-persistence-limits.ts create mode 100644 src/main/integration-api-concurrency.test.ts create mode 100644 src/main/integration-api-concurrency.ts create mode 100644 src/main/integration-credential-file.test.ts create mode 100644 src/main/integration-error-message.ts create mode 100644 src/main/integration-fanout.test.ts create mode 100644 src/main/integration-fanout.ts create mode 100644 src/main/integration-pagination-budget.test.ts create mode 100644 src/main/integration-pagination-budget.ts create mode 100644 src/main/ipc/ai-vault-host-scan-batches.ts create mode 100644 src/main/ipc/custom-pet-file-reader.test.ts create mode 100644 src/main/ipc/custom-pet-file-reader.ts create mode 100644 src/main/ipc/emulator-video-frame-delivery.test.ts create mode 100644 src/main/ipc/emulator-video-frame-delivery.ts create mode 100644 src/main/ipc/emulator-video-stream-admission.test.ts create mode 100644 src/main/ipc/filesystem-directory-reader.test.ts create mode 100644 src/main/ipc/filesystem-directory-reader.ts create mode 100644 src/main/ipc/filesystem-external-import-limits.test.ts create mode 100644 src/main/ipc/filesystem-external-import-limits.ts create mode 100644 src/main/ipc/filesystem-runtime-upload-file-reader.test.ts create mode 100644 src/main/ipc/filesystem-runtime-upload-file-reader.ts create mode 100644 src/main/ipc/filesystem-watcher-admission.test.ts create mode 100644 src/main/ipc/filesystem-watcher-admission.ts delete mode 100644 src/main/ipc/filesystem-watcher-dormant-rearm.test.ts delete mode 100644 src/main/ipc/filesystem-watcher-remote-rearm.test.ts create mode 100644 src/main/ipc/local-log-tail-operation-admission.ts create mode 100644 src/main/ipc/native-chat-ipc-admission.ts create mode 100644 src/main/ipc/native-chat-subscription-registry.ts create mode 100644 src/main/ipc/native-notification-retention.ts create mode 100644 src/main/ipc/notification-ipc-admission.test.ts create mode 100644 src/main/ipc/notification-ipc-admission.ts create mode 100644 src/main/ipc/pty-external-renderer-delivery.test.ts create mode 100644 src/main/ipc/pty-pending-data-drain-contract.ts create mode 100644 src/main/ipc/pty-pending-data-drain-queue-differential.test.ts create mode 100644 src/main/ipc/pty-pending-data-drain-queue.test.ts create mode 100644 src/main/ipc/pty-pending-data-drain-queue.ts create mode 100644 src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts create mode 100644 src/main/ipc/pty-renderer-delivery-credit.test.ts create mode 100644 src/main/ipc/pty-renderer-delivery-credit.ts create mode 100644 src/main/ipc/pty-renderer-delivery-retention.test.ts create mode 100644 src/main/ipc/pty-renderer-delivery-retention.ts create mode 100644 src/main/ipc/pty-renderer-delivery-router.ts create mode 100644 src/main/ipc/repo-target-directory-emptiness.ts create mode 100644 src/main/ipc/repos-add-linked-worktree.test.ts create mode 100644 src/main/ipc/runtime-environment-shared-control-support.ts create mode 100644 src/main/ipc/runtime-environment-subscription-admission.test.ts create mode 100644 src/main/ipc/runtime-environment-subscription-admission.ts create mode 100644 src/main/ipc/runtime-environment-subscription-handlers.ts create mode 100644 src/main/ipc/runtime-environment-transport-generation.test.ts create mode 100644 src/main/ipc/shell-repo-icon-picker.ts create mode 100644 src/main/ipc/source-control-ai-linked-issue.test.ts create mode 100644 src/main/ipc/source-control-ai-linked-issue.ts create mode 100644 src/main/ipc/speech-ipc-admission.ts create mode 100644 src/main/ipc/ssh-passphrase.test.ts create mode 100644 src/main/ipc/worktree-base-directory-snapshot.ts create mode 100644 src/main/ipc/worktree-base-directory-watch-targets-bounds.test.ts create mode 100644 src/main/ipc/worktree-common-git-directory.test.ts create mode 100644 src/main/ipc/worktree-git-common-snapshot.ts create mode 100644 src/main/ipc/worktree-include-copy-budget.test.ts create mode 100644 src/main/ipc/worktree-include-copy-budget.ts create mode 100644 src/main/ipc/worktree-polling-scan-budget.test.ts create mode 100644 src/main/ipc/worktree-polling-scan-budget.ts delete mode 100644 src/main/ipc/worktree-symlink-reconciliation.real.test.ts create mode 100644 src/main/ipc/wsl-watcher-snapshot.test.ts create mode 100644 src/main/ipc/wsl-watcher-snapshot.ts create mode 100644 src/main/lib/fetch-response-body.test.ts create mode 100644 src/main/lib/fetch-response-body.ts create mode 100644 src/main/lib/html-to-pdf-memory-limit.test.ts create mode 100644 src/main/lib/html-to-pdf-memory-limit.ts create mode 100644 src/main/lib/html-to-pdf.test.ts create mode 100644 src/main/linear/issue-context-fanout-reads.ts create mode 100644 src/main/linear/issue-context-pagination.test.ts create mode 100644 src/main/linear/linear-project-request-coalescer.ts create mode 100644 src/main/linear/linear-team-pages.test.ts create mode 100644 src/main/local-downloaded-folder-promotion-budget.test.ts create mode 100644 src/main/local-downloaded-folder-promotion-budget.ts create mode 100644 src/main/local-downloaded-folder-promotion-traversal.ts create mode 100644 src/main/native-chat/transcript-incremental-reader.test.ts create mode 100644 src/main/native-chat/transcript-message-retention.test.ts create mode 100644 src/main/native-chat/transcript-message-retention.ts create mode 100644 src/main/native-chat/transcript-read-admission.test.ts create mode 100644 src/main/native-chat/transcript-read-admission.ts create mode 100644 src/main/native-chat/transcript-record-buffer.test.ts create mode 100644 src/main/native-chat/transcript-record-buffer.ts create mode 100644 src/main/opencode-usage/sqlite-usage-row-stream.test.ts create mode 100644 src/main/opencode-usage/sqlite-usage-row-stream.ts create mode 100644 src/main/opencode/config-overlay-manifest.test.ts create mode 100644 src/main/opencode/config-overlay-manifest.ts create mode 100644 src/main/opencode/opencode-database-files.ts create mode 100644 src/main/pi/legacy-omp-overlay-migration-budget.ts create mode 100644 src/main/pi/managed-extension-ownership.test.ts create mode 100644 src/main/pi/managed-extension-ownership.ts create mode 100644 src/main/providers/filesystem-path-listing-provider.ts create mode 100644 src/main/providers/ssh-filesystem-directory-reader.ts delete mode 100644 src/main/providers/ssh-filesystem-dispatch.test.ts create mode 100644 src/main/providers/ssh-filesystem-provider-directory-limits.test.ts create mode 100644 src/main/providers/ssh-markdown-document-listing.test.ts create mode 100644 src/main/providers/ssh-markdown-document-listing.ts create mode 100644 src/main/providers/ssh-pty-live-roster.test.ts create mode 100644 src/main/providers/ssh-pty-live-roster.ts delete mode 100644 src/main/providers/ssh-pty-notification-routing.ts create mode 100644 src/main/providers/ssh-pty-output-delivery-credit.test.ts create mode 100644 src/main/providers/ssh-pty-output-delivery-credit.ts create mode 100644 src/main/providers/ssh-pty-provider-notifications.ts create mode 100644 src/main/providers/ssh-pty-spawn-exit-race.test.ts create mode 100644 src/main/providers/ssh-pty-wire-admission.ts create mode 100644 src/main/pty/config-overlay-mirroring.test.ts create mode 100644 src/main/pty/config-overlay-mirroring.ts create mode 100644 src/main/pty/shell-startup-env-memory.test.ts create mode 100644 src/main/rate-limits/rate-limit-pty-output-tail.test.ts create mode 100644 src/main/rate-limits/rate-limit-pty-output-tail.ts create mode 100644 src/main/repo-git-remote-identity.test.ts create mode 100644 src/main/repo-icon-file-detection.ts create mode 100644 src/main/repo-location-cache-key.ts create mode 100644 src/main/runtime/client-session-tab-selection-retention.ts delete mode 100644 src/main/runtime/orca-runtime-files-ssh-rearm.test.ts create mode 100644 src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts create mode 100644 src/main/runtime/orchestration-message-waiter-registry.test.ts create mode 100644 src/main/runtime/orchestration-message-waiter-registry.ts create mode 100644 src/main/runtime/orchestration/query-retention.ts create mode 100644 src/main/runtime/relay/relay-control-requests.test.ts create mode 100644 src/main/runtime/relay/relay-host-proof-bounds.test.ts create mode 100644 src/main/runtime/rpc/dispatcher-output-bounds.test.ts create mode 100644 src/main/runtime/rpc/methods/clipboard-image-upload-buffer.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-query-retention.test.ts create mode 100644 src/main/runtime/rpc/runtime-rpc-response-serialization.ts create mode 100644 src/main/runtime/runtime-file-watcher-admission.test.ts create mode 100644 src/main/runtime/runtime-file-watcher-admission.ts create mode 100644 src/main/runtime/runtime-operation-generations.test.ts create mode 100644 src/main/runtime/runtime-operation-generations.ts create mode 100644 src/main/runtime/runtime-ssh-relay-recovery-generations.test.ts create mode 100644 src/main/runtime/runtime-ssh-relay-recovery-generations.ts create mode 100644 src/main/runtime/runtime-terminal-file-grant-retention.test.ts create mode 100644 src/main/runtime/runtime-terminal-file-grant-retention.ts create mode 100644 src/main/runtime/runtime-text-search-admission.test.ts create mode 100644 src/main/runtime/runtime-text-search-admission.ts create mode 100644 src/main/setup-script-import-file.test.ts create mode 100644 src/main/setup-script-import-file.ts create mode 100644 src/main/skills/skill-discovery-limits.test.ts create mode 100644 src/main/skills/skill-discovery-limits.ts create mode 100644 src/main/skills/wsl-skill-protocol-fields.ts create mode 100644 src/main/source-control/pull-request-template.test.ts create mode 100644 src/main/source-control/repository-ref-cache.test.ts create mode 100644 src/main/source-control/repository-ref-cache.ts create mode 100644 src/main/speech/speech-model-directory-scanner.test.ts create mode 100644 src/main/speech/speech-model-directory-scanner.ts create mode 100644 src/main/speech/speech-model-extraction-stderr.test.ts create mode 100644 src/main/speech/speech-model-extraction-stderr.ts create mode 100644 src/main/ssh/sftp-directory-removal.ts create mode 100644 src/main/ssh/sftp-namespace-resolution.test.ts create mode 100644 src/main/ssh/sftp-namespace-resolution.ts create mode 100644 src/main/ssh/ssh-config-bounded-glob.test.ts create mode 100644 src/main/ssh/ssh-config-bounded-glob.ts create mode 100644 src/main/ssh/ssh-config-expansion-budget.ts create mode 100644 src/main/ssh/ssh-config-include-expansion-bounds.test.ts create mode 100644 src/main/ssh/ssh-connection-sftp-namespace.test.ts create mode 100644 src/main/ssh/ssh-connection-sftp-wire.test.ts create mode 100644 src/main/ssh/ssh-directory-transfer-budget.test.ts create mode 100644 src/main/ssh/ssh-directory-transfer-budget.ts create mode 100644 src/main/ssh/ssh-file-stream-assembler.ts create mode 100644 src/main/ssh/ssh-git-response-stream-assembler.ts create mode 100644 src/main/ssh/ssh-key-file.test.ts create mode 100644 src/main/ssh/ssh-key-file.ts create mode 100644 src/main/ssh/ssh-relay-base-directory-listing.test.ts create mode 100644 src/main/ssh/ssh-relay-base-directory-listing.ts create mode 100644 src/main/ssh/ssh-relay-bounded-marker-commands.test.ts create mode 100644 src/main/ssh/ssh-relay-bounded-marker-commands.ts create mode 100644 src/main/ssh/ssh-relay-install-marker.ts create mode 100644 src/main/ssh/ssh-relay-install-namespace.test.ts create mode 100644 src/main/ssh/ssh-relay-install-namespace.ts create mode 100644 src/main/ssh/ssh-relay-install-transfers.ts create mode 100644 src/main/ssh/ssh-relay-liveness-probe-source.test.ts create mode 100644 src/main/ssh/ssh-relay-liveness-probe-source.ts create mode 100644 src/main/ssh/ssh-relay-sftp-namespace-install.test.ts create mode 100644 src/main/ssh/ssh-remote-node-candidate-probe.test.ts create mode 100644 src/main/ssh/ssh-remote-node-candidate-probe.ts create mode 100644 src/main/ssh/ssh-stream-reader-memory.test.ts create mode 100644 src/main/ssh/ssh-stream-reader-memory.ts create mode 100644 src/main/ssh/ssh-windows-log-tail-command.test.ts create mode 100644 src/main/ssh/ssh-windows-log-tail-command.ts create mode 100644 src/main/ssh/system-ssh-operation-lifecycle.test.ts create mode 100644 src/main/ssh/system-ssh-output-tail.test.ts create mode 100644 src/main/ssh/system-ssh-output-tail.ts create mode 100644 src/main/stats/stats-aggregate-tracker.ts create mode 100644 src/main/stats/stats-file-json-admission.test.ts create mode 100644 src/main/stats/stats-retention.ts create mode 100644 src/main/terminal-history-gc.test.ts create mode 100644 src/main/terminal-history-gc.ts create mode 100644 src/main/text-generation/local-ai-process-budget.ts create mode 100644 src/main/usage-history-file-discovery.ts create mode 100644 src/main/usage-history-jsonl-reader.test.ts create mode 100644 src/main/usage-history-jsonl-reader.ts create mode 100644 src/main/usage-history-scan-budget.test.ts create mode 100644 src/main/usage-history-scan-budget.ts create mode 100644 src/main/usage-projection-state-file.test.ts create mode 100644 src/main/usage-projection-state-file.ts create mode 100644 src/preload/emulator-stream-frame-listeners.test.ts create mode 100644 src/preload/emulator-stream-frame-listeners.ts create mode 100644 src/preload/renderer-heap-statistics-reader.test.ts create mode 100644 src/preload/renderer-heap-statistics-reader.ts create mode 100644 src/relay/client-request-aborts.test.ts create mode 100644 src/relay/filesystem-directory-reader.test.ts create mode 100644 src/relay/filesystem-directory-reader.ts create mode 100644 src/relay/git-handler-status-ops-bounds.test.ts create mode 100644 src/relay/git-handler-stream-serialization-memory.test.ts create mode 100644 src/relay/git-response-utf8-chunks.test.ts create mode 100644 src/relay/git-response-utf8-chunks.ts create mode 100644 src/relay/markdown-document-listing.test.ts create mode 100644 src/relay/markdown-document-listing.ts create mode 100644 src/relay/mobile-file-directory-reader.test.ts create mode 100644 src/relay/mobile-file-directory-reader.ts create mode 100644 src/relay/protocol-json-encoding-memory.test.ts create mode 100644 src/relay/pty-output-broadcast.test.ts create mode 100644 src/relay/pty-output-broadcast.ts create mode 100644 src/relay/pty-output-flow-state.ts create mode 100644 src/relay/pty-persistence-envelope.test.ts create mode 100644 src/relay/pty-persistence-envelope.ts create mode 100644 src/relay/relay-watcher-root-capacity.test.ts create mode 100644 src/relay/wsl-install-plugins-handler.test.ts create mode 100644 src/relay/wsl-install-plugins-handler.ts create mode 100644 src/renderer/src/components/editor/base64-byte-decoder.test.ts create mode 100644 src/renderer/src/components/editor/base64-byte-decoder.ts create mode 100644 src/renderer/src/components/editor/combined-diff-text-retention.test.ts create mode 100644 src/renderer/src/components/editor/combined-diff-text-retention.ts create mode 100644 src/renderer/src/components/editor/ipynb-json-admission.test.ts create mode 100644 src/renderer/src/components/editor/ipynb-json-admission.ts create mode 100644 src/renderer/src/components/editor/local-image-blob-retention.test.ts create mode 100644 src/renderer/src/components/editor/local-image-blob-retention.ts create mode 100644 src/renderer/src/components/editor/local-image-load-admission.test.ts create mode 100644 src/renderer/src/components/editor/local-image-load-admission.ts create mode 100644 src/renderer/src/components/editor/markdown-document-worktree-retention.test.ts create mode 100644 src/renderer/src/components/editor/markdown-document-worktree-retention.ts create mode 100644 src/renderer/src/components/editor/monaco-markdown-doc-completions.test.ts create mode 100644 src/renderer/src/components/editor/pending-editor-focus-request.test.ts create mode 100644 src/renderer/src/components/editor/pending-editor-focus-request.ts create mode 100644 src/renderer/src/components/editor/useMarkdownDocuments.test.tsx create mode 100644 src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts delete mode 100644 src/renderer/src/components/github-item-dialog-source-boundary.test.ts delete mode 100644 src/renderer/src/components/pull-request-page-host-boundary.test.ts create mode 100644 src/renderer/src/components/settings/appearance-interface-summary.test.ts create mode 100644 src/renderer/src/components/settings/appearance-interface-summary.ts create mode 100644 src/renderer/src/components/settings/developer-permissions-search.test.ts create mode 100644 src/renderer/src/components/settings/osc52-clipboard-copy.test.ts create mode 100644 src/renderer/src/components/status-bar/hosted-review-label.test.ts create mode 100644 src/renderer/src/components/status-bar/hosted-review-label.ts create mode 100644 src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts create mode 100644 src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.ts create mode 100644 src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pane-title-overlay-rects.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts create mode 100644 src/renderer/src/hooks/metadata-retention-measurement.ts create mode 100644 src/renderer/src/hooks/unknown-ssh-target-state-coordinator.test.ts create mode 100644 src/renderer/src/hooks/unknown-ssh-target-state-coordinator.ts create mode 100644 src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx create mode 100644 src/renderer/src/hooks/worktree-rename-grace-retention.test.ts create mode 100644 src/renderer/src/hooks/worktree-rename-grace-retention.ts delete mode 100644 src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts create mode 100644 src/renderer/src/lib/github-work-item-details-cache.test.tsx create mode 100644 src/renderer/src/lib/github-work-item-details-cache.ts create mode 100644 src/renderer/src/lib/github-work-item-details-retained-bytes.ts create mode 100644 src/renderer/src/lib/repo-slug-resolution-pool.test.ts create mode 100644 src/renderer/src/lib/repo-slug-resolution-pool.ts create mode 100644 src/renderer/src/lib/transient-agent-status-clear-retention.test.ts create mode 100644 src/renderer/src/lib/transient-agent-status-clear-retention.ts create mode 100644 src/renderer/src/runtime/web-session-snapshot-recovery-pool.test.ts create mode 100644 src/renderer/src/runtime/web-session-snapshot-recovery-pool.ts create mode 100644 src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.test.ts create mode 100644 src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.ts create mode 100644 src/renderer/src/startup/ssh-startup-reconnect-fanout.test.ts create mode 100644 src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts create mode 100644 src/renderer/src/store/slices/ssh-credential-request-retention.test.ts create mode 100644 src/renderer/src/store/slices/ssh-credential-request-retention.ts create mode 100644 src/renderer/src/web/web-e2ee.test.ts create mode 100644 src/renderer/src/web/web-local-storage-json.test.ts create mode 100644 src/renderer/src/web/web-local-storage-json.ts create mode 100644 src/renderer/src/web/web-preload-request-owners.test.ts create mode 100644 src/renderer/src/web/web-preload-request-owners.ts create mode 100644 src/renderer/src/web/web-runtime-inbound-json.test.ts create mode 100644 src/renderer/src/web/web-runtime-inbound-json.ts create mode 100644 src/renderer/src/web/web-runtime-outbound-json.test.ts create mode 100644 src/renderer/src/web/web-runtime-outbound-json.ts create mode 100644 src/renderer/src/web/web-runtime-outbound-memory-budget.test.ts create mode 100644 src/renderer/src/web/web-runtime-outbound-memory-budget.ts create mode 100644 src/shared/custom-pet-media-limits.ts create mode 100644 src/shared/filesystem-directory-listing-limit.test.ts create mode 100644 src/shared/filesystem-directory-listing-limit.ts create mode 100644 src/shared/hermes-run-output-limits.test.ts create mode 100644 src/shared/hermes-run-output-limits.ts create mode 100644 src/shared/hermes-run-ref-retention.test.ts create mode 100644 src/shared/hermes-run-ref-retention.ts create mode 100644 src/shared/html-to-pdf-memory-limit.ts create mode 100644 src/shared/linux-proc-port-scan-limits.test.ts create mode 100644 src/shared/linux-proc-port-scan-limits.ts create mode 100644 src/shared/mobile-file-directory-limit.test.ts create mode 100644 src/shared/mobile-file-directory-limit.ts create mode 100644 src/shared/osc52-clipboard-settings.test.ts create mode 100644 src/shared/osc52-clipboard-settings.ts create mode 100644 src/shared/persisted-state-file-bounds.test.ts create mode 100644 src/shared/persisted-state-file-bounds.ts create mode 100644 src/shared/pr-refresh-memory-limits.ts create mode 100644 src/shared/quick-open-listing-limits.test.ts create mode 100644 src/shared/quick-open-listing-limits.ts create mode 100644 src/shared/relay-json-admission.ts create mode 100644 src/shared/renderer-heap-statistics.ts create mode 100644 src/shared/source-control-ai-action-variables.test.ts create mode 100644 src/shared/source-control-ai-action-variables.ts create mode 100644 src/shared/ssh-retained-payload-admission.test.ts create mode 100644 src/shared/ssh-retained-payload-admission.ts create mode 100644 src/shared/terminal-size-limits.ts create mode 100644 tests/e2e/codex-composer-echo-latency-probe.ts create mode 100644 tests/e2e/helpers/alt-screen-frame.ts create mode 100644 tests/e2e/source-control-pr-linked-issue-ai.spec.ts delete mode 100644 tests/e2e/ssh-external-image-preview.spec.ts diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 3023d414abe..784172d699e 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -117,6 +117,7 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm build:electron-vite + - run: pnpm verify:cli-runtime # Why: boot the BUILT daemon-entry under plain Node the way production # forks it. v1.4.129-rc.1 shipped a daemon that exited at module load # (leaked electron require) while every other check passed; this fails @@ -158,6 +159,7 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm build:electron-vite + - run: pnpm verify:cli-runtime - run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts linux: @@ -185,6 +187,7 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm build:electron-vite + - run: pnpm verify:cli-runtime - run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts windows: @@ -204,4 +207,5 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm build:electron-vite + - run: pnpm verify:cli-runtime - run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e444a498f0f..e089c9b2bc2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -113,6 +113,14 @@ jobs: - name: Verify macOS entitlements run: pnpm verify:macos-entitlements + # Why: a branch built by overwriting files (instead of merging) silently reverts shipped work. + # Typecheck and tests cannot see it — a reverted feature still compiles and its old tests pass. + - name: Check for reverted merge-base work + if: github.event_name == 'pull_request' + run: | + git fetch --no-tags --depth=200 origin "${{ github.base_ref }}" + node config/scripts/check-no-merge-base-revert.mjs --base "origin/${{ github.base_ref }}" --head HEAD + - name: Typecheck run: pnpm typecheck diff --git a/config/bounded-ingress-baseline.json b/config/bounded-ingress-baseline.json index 4e699cdf1f4..5eb69e5b7d1 100644 --- a/config/bounded-ingress-baseline.json +++ b/config/bounded-ingress-baseline.json @@ -1,6 +1,5 @@ { "allowed": { - "mobile/app/h/[hostId]/session/[worktreeId].tsx:json-stringify": 1, "mobile/app/index.tsx:json-parse": 1, "mobile/src/cache/home-snapshot-cache.ts:json-parse": 1, "mobile/src/cache/home-snapshot-cache.ts:json-stringify": 1, @@ -11,9 +10,10 @@ "mobile/src/components/pr-sidebar/MermaidDiagram.tsx:json-stringify": 1, "mobile/src/files/mobile-file-preview-source.ts:json-stringify": 2, "mobile/src/session/MobileNativeChatView.tsx:json-stringify": 1, - "mobile/src/session/mobile-image-source-picker.ts:response-text": 1, "mobile/src/session/mobile-native-chat-permission.ts:json-parse": 1, "mobile/src/session/mobile-native-chat-question.ts:json-stringify": 1, + "mobile/src/session/mobile-session-file-doc-lifecycle.ts:json-stringify": 1, + "mobile/src/session/mobile-session-markdown-doc-lifecycle.ts:json-stringify": 1, "mobile/src/session/mobile-terminal-records.ts:json-stringify": 4, "mobile/src/session/use-mobile-native-chat-ask-dismiss.ts:json-stringify": 1, "mobile/src/storage/codex-reset-attempt-journal.ts:json-parse": 1, @@ -22,6 +22,7 @@ "mobile/src/storage/preferences.ts:json-stringify": 2, "mobile/src/storage/session-view-preferences.ts:json-parse": 1, "mobile/src/storage/session-view-preferences.ts:json-stringify": 1, + "mobile/src/tasks/mobile-pr-file-content-cache.ts:json-stringify": 1, "mobile/src/terminal/TerminalWebView.tsx:json-parse": 1, "mobile/src/terminal/TerminalWebView.tsx:json-stringify": 2, "mobile/src/terminal/terminal-accessory-layout.ts:json-parse": 1, @@ -29,51 +30,33 @@ "mobile/src/terminal/terminal-webview-html.ts:json-parse": 1, "mobile/src/terminal/terminal-webview-html.ts:json-stringify": 4, "mobile/src/terminal/terminal-webview-url-tap.ts:json-stringify": 2, - "mobile/src/transport/browser-screencast-protocol.ts:json-parse": 1, - "mobile/src/transport/host-credential-cleanup.ts:json-parse": 1, "mobile/src/transport/host-credential-cleanup.ts:json-stringify": 1, - "mobile/src/transport/host-store.ts:json-parse": 1, - "mobile/src/transport/host-store.ts:json-stringify": 1, - "mobile/src/transport/mobile-e2ee-v2-physical-channel.ts:json-parse": 3, - "mobile/src/transport/mobile-e2ee-v2-physical-channel.ts:json-stringify": 2, - "mobile/src/transport/mobile-relay-credential-bundle.ts:json-parse": 1, + "mobile/src/transport/mobile-direct-rpc-outbound.ts:json-parse": 1, + "mobile/src/transport/mobile-e2ee-v2-physical-channel.ts:json-stringify": 1, + "mobile/src/transport/mobile-host-list-storage.ts:json-stringify": 1, + "mobile/src/transport/mobile-json-text-admission.ts:json-parse": 1, + "mobile/src/transport/mobile-outbound-json.ts:json-stringify": 1, "mobile/src/transport/mobile-relay-credential-bundle.ts:json-stringify": 1, "mobile/src/transport/mobile-relay-credential-rotation.ts:json-stringify": 1, - "mobile/src/transport/mobile-relay-direct-upgrade-journal.ts:json-parse": 1, "mobile/src/transport/mobile-relay-direct-upgrade-journal.ts:json-stringify": 1, "mobile/src/transport/mobile-relay-direct-upgrade.ts:json-stringify": 1, - "mobile/src/transport/mobile-relay-e2ee-link.ts:json-parse": 1, "mobile/src/transport/mobile-relay-e2ee-link.ts:json-stringify": 1, - "mobile/src/transport/mobile-relay-host-overlay-store.ts:json-parse": 1, "mobile/src/transport/mobile-relay-host-overlay-store.ts:json-stringify": 1, - "mobile/src/transport/mobile-relay-invite-director.ts:json-parse": 1, "mobile/src/transport/mobile-relay-invite-director.ts:json-stringify": 1, - "mobile/src/transport/mobile-relay-pairing-journal-store.ts:json-parse": 2, - "mobile/src/transport/mobile-relay-pairing-journal-store.ts:json-stringify": 3, + "mobile/src/transport/mobile-relay-pairing-journal-store.ts:json-stringify": 2, "mobile/src/transport/mobile-relay-pairing-journal.ts:json-stringify": 1, "mobile/src/transport/mobile-relay-pairing-recovery.ts:json-stringify": 1, - "mobile/src/transport/mobile-relay-physical-client.ts:json-parse": 2, - "mobile/src/transport/mobile-relay-physical-client.ts:json-stringify": 2, - "mobile/src/transport/mobile-relay-resume-director.ts:json-parse": 1, + "mobile/src/transport/mobile-relay-physical-client.ts:json-stringify": 1, "mobile/src/transport/mobile-relay-resume-director.ts:json-stringify": 1, - "mobile/src/transport/mobile-relay-resume-director.ts:response-text": 1, - "mobile/src/transport/mobile-relay-rpc-session.ts:json-parse": 1, - "mobile/src/transport/mobile-relay-rpc-session.ts:json-stringify": 1, - "mobile/src/transport/pairing.ts:json-parse": 1, "mobile/src/transport/pre-profile-pairing-coordinator.ts:json-stringify": 1, - "mobile/src/transport/rpc-client.ts:json-parse": 3, - "mobile/src/transport/rpc-client.ts:json-stringify": 2, + "mobile/src/transport/rpc-client.ts:json-stringify": 1, "mobile/src/transport/socket-event-debug.ts:json-stringify": 1, - "mobile/src/transport/terminal-stream-protocol.ts:json-parse": 1, "mobile/src/transport/websocket-payload-bytes.ts:response-text": 1, "mobile/src/worktree/last-visited-worktree-repo.ts:json-parse": 1, "src/cli/bundled-skill-guides.ts:json-parse": 1, "src/cli/bundled-skill-guides.ts:json-stringify": 1, "src/cli/bundled-skill-guides.ts:read-file": 1, "src/cli/format.ts:json-stringify": 3, - "src/cli/handlers/agent-hooks.ts:json-parse": 1, - "src/cli/handlers/agent-hooks.ts:json-stringify": 1, - "src/cli/handlers/agent-hooks.ts:read-file": 1, "src/cli/handlers/automations.ts:json-parse": 1, "src/cli/handlers/browser-env.ts:json-stringify": 1, "src/cli/handlers/browser-interact.ts:json-stringify": 2, @@ -86,8 +69,6 @@ "src/cli/handlers/orchestration.ts:json-stringify": 3, "src/cli/handlers/skills.ts:json-stringify": 2, "src/cli/handlers/vm.ts:json-stringify": 1, - "src/cli/handlers/vm.ts:read-file": 1, - "src/cli/linear-request-builders.ts:read-file": 1, "src/cli/runtime/mac-app-update-bundle.ts:read-file": 1, "src/cli/runtime/metadata.ts:json-parse": 2, "src/cli/runtime/metadata.ts:read-file": 2, @@ -100,83 +81,42 @@ "src/main/active-view-preference.ts:json-parse": 1, "src/main/active-view-preference.ts:json-stringify": 1, "src/main/active-view-preference.ts:read-file": 1, + "src/main/agent-hooks/agent-hook-sftp-text-reader.ts:read-file": 1, "src/main/agent-hooks/hooks-json-read.ts:json-parse": 1, - "src/main/agent-hooks/hooks-json-read.ts:read-file": 1, "src/main/agent-hooks/install-telemetry.ts:json-stringify": 1, - "src/main/agent-hooks/installer-utils-remote.ts:json-parse": 1, "src/main/agent-hooks/installer-utils-remote.ts:json-stringify": 1, - "src/main/agent-hooks/installer-utils-remote.ts:read-file": 7, + "src/main/agent-hooks/installer-utils-remote.ts:read-file": 6, "src/main/agent-hooks/installer-utils.ts:json-stringify": 1, - "src/main/agent-hooks/installer-utils.ts:read-file": 2, "src/main/agent-hooks/managed-hook-local-filesystem.ts:read-file": 2, "src/main/agent-hooks/managed-hook-lock-claims.ts:json-stringify": 1, - "src/main/agent-hooks/managed-hook-lock-claims.ts:read-file": 2, "src/main/agent-hooks/managed-hook-lock-records.ts:json-parse": 3, "src/main/agent-hooks/managed-hook-lock-records.ts:json-stringify": 1, - "src/main/agent-hooks/managed-hook-lock-records.ts:read-file": 3, - "src/main/agent-hooks/managed-hook-lock-records.ts:unbounded-fanout": 1, - "src/main/agent-hooks/managed-hook-owner-identity.ts:read-file": 3, + "src/main/agent-hooks/managed-hook-owner-identity.ts:read-file": 2, "src/main/agent-hooks/server.ts:json-parse": 1, "src/main/agent-hooks/server.ts:json-stringify": 1, - "src/main/agent-hooks/server.ts:read-file": 1, "src/main/agent-hooks/wsl-hook-fs-adapter.ts:read-file": 1, "src/main/agent-hooks/wsl-hook-relay-deps.ts:read-file": 1, "src/main/agent-hooks/wsl-hook-relay-launch.ts:read-file": 1, "src/main/agent-state-file-reader.ts:json-parse": 1, - "src/main/agent-trust-presets.ts:json-parse": 1, "src/main/agent-trust-presets.ts:json-stringify": 2, - "src/main/agent-trust-presets.ts:read-file": 3, - "src/main/ai-vault/cached-session-list.ts:json-stringify": 1, "src/main/ai-vault/remote-session-scanner-codex-index.ts:read-file": 1, "src/main/ai-vault/remote-session-scanner-discovery.ts:unbounded-fanout": 1, "src/main/ai-vault/remote-session-scanner.ts:read-file": 2, "src/main/ai-vault/remote-session-scanner.ts:unbounded-fanout": 3, - "src/main/ai-vault/session-parse-cache-persistence.ts:json-parse": 1, - "src/main/ai-vault/session-parse-cache-persistence.ts:json-stringify": 1, - "src/main/ai-vault/session-parse-cache-persistence.ts:read-file": 1, "src/main/ai-vault/session-scanner-antigravity-fixtures.ts:json-stringify": 1, - "src/main/ai-vault/session-scanner-claude-subagents.ts:json-parse": 1, - "src/main/ai-vault/session-scanner-claude-subagents.ts:read-file": 1, "src/main/ai-vault/session-scanner-claude-subagents.ts:unbounded-fanout": 1, "src/main/ai-vault/session-scanner-codex-fixtures.ts:json-stringify": 1, - "src/main/ai-vault/session-scanner-devin-parser.ts:json-parse": 1, - "src/main/ai-vault/session-scanner-devin-parser.ts:read-file": 1, - "src/main/ai-vault/session-scanner-discovery.ts:unbounded-fanout": 1, - "src/main/ai-vault/session-scanner-gemini-parsers.ts:json-parse": 1, - "src/main/ai-vault/session-scanner-gemini-parsers.ts:read-file": 1, - "src/main/ai-vault/session-scanner-graph-parsers.ts:json-parse": 1, - "src/main/ai-vault/session-scanner-graph-parsers.ts:read-file": 1, - "src/main/ai-vault/session-scanner-grok-parser.ts:json-parse": 1, - "src/main/ai-vault/session-scanner-grok-parser.ts:read-file": 1, "src/main/ai-vault/session-scanner-incremental-fixtures.ts:json-stringify": 25, - "src/main/ai-vault/session-scanner-kimi-parser.ts:json-parse": 1, - "src/main/ai-vault/session-scanner-kimi-parser.ts:read-file": 1, - "src/main/ai-vault/session-scanner-kimi-paths.ts:json-parse": 1, - "src/main/ai-vault/session-scanner-opencode-sqlite.ts:json-parse": 2, - "src/main/ai-vault/session-scanner-secondary-parsers.ts:json-parse": 3, - "src/main/ai-vault/session-scanner-secondary-parsers.ts:read-file": 3, "src/main/ai-vault/session-scanner-test-fixtures.ts:json-stringify": 1, - "src/main/ai-vault/session-scanner-values.ts:json-parse": 2, - "src/main/ai-vault/session-scanner-values.ts:read-file": 1, - "src/main/ai-vault/session-scanner.ts:read-file": 1, + "src/main/ai-vault/session-scanner-values.ts:json-parse": 1, "src/main/ai-vault/session-scanner.ts:unbounded-fanout": 1, "src/main/amp/hook-service.ts:json-stringify": 2, - "src/main/amp/hook-service.ts:read-file": 3, "src/main/attribution/terminal-attribution.ts:read-file": 1, - "src/main/automations/external-manager.ts:json-parse": 2, - "src/main/automations/external-manager.ts:read-file": 2, - "src/main/automations/external-manager.ts:unbounded-fanout": 1, - "src/main/automations/hermes-cron-output.ts:read-file": 2, - "src/main/automations/hermes-cron-output.ts:unbounded-fanout": 1, - "src/main/azure-devops/azure-devops-api-request.ts:response-text": 1, "src/main/azure-devops/pull-request-creation.ts:json-stringify": 1, - "src/main/bitbucket/client.ts:response-text": 1, "src/main/browser/agent-browser-bridge.ts:json-parse": 2, "src/main/browser/agent-browser-bridge.ts:json-stringify": 9, - "src/main/browser/agent-browser-bridge.ts:read-file": 1, "src/main/browser/browser-clicked-link-routing.ts:json-stringify": 2, - "src/main/browser/browser-cookie-import.ts:json-parse": 3, - "src/main/browser/browser-cookie-import.ts:read-file": 4, + "src/main/browser/browser-cookie-import.ts:json-parse": 2, "src/main/browser/browser-manager.ts:json-stringify": 24, "src/main/browser/browser-session-registry.ts:json-parse": 1, "src/main/browser/browser-session-registry.ts:json-stringify": 1, @@ -193,29 +133,24 @@ "src/main/claude-accounts/managed-auth-path.ts:read-file": 2, "src/main/claude-accounts/oauth-refresh.ts:json-parse": 2, "src/main/claude-accounts/oauth-refresh.ts:json-stringify": 1, - "src/main/claude-accounts/oauth-refresh.ts:response-text": 1, - "src/main/claude-accounts/runtime-auth-service.ts:json-parse": 8, + "src/main/claude-accounts/runtime-auth-service.ts:json-parse": 5, "src/main/claude-accounts/runtime-auth-service.ts:json-stringify": 3, - "src/main/claude-accounts/runtime-auth-service.ts:read-file": 10, - "src/main/claude-accounts/service.ts:json-parse": 2, + "src/main/claude-accounts/service.ts:json-parse": 1, "src/main/claude-accounts/service.ts:json-stringify": 2, - "src/main/claude-accounts/service.ts:read-file": 2, "src/main/claude-usage/scanner.ts:json-parse": 1, - "src/main/claude-usage/scanner.ts:unbounded-fanout": 3, + "src/main/claude-usage/scanner.ts:unbounded-fanout": 2, "src/main/claude-usage/store.ts:json-parse": 1, "src/main/claude-usage/store.ts:json-stringify": 3, "src/main/claude-usage/store.ts:read-file": 1, "src/main/claude/hook-settings.ts:json-stringify": 1, - "src/main/cli/cli-installer.ts:read-file": 3, - "src/main/cli/linux-bare-orca-dispatcher.ts:read-file": 1, "src/main/cli/linux-terminal-orca-cli-shim.ts:read-file": 1, "src/main/cli/wsl-cli-registration-registry.ts:json-parse": 1, "src/main/cli/wsl-cli-registration-registry.ts:json-stringify": 1, "src/main/cli/wsl-cli-registration-registry.ts:read-file": 1, "src/main/codex-accounts/codex-auth-identity.ts:json-parse": 1, "src/main/codex-accounts/host-codex-managed-home-ownership.ts:read-file": 1, + "src/main/codex-accounts/legacy-managed-state-migration.ts:json-stringify": 2, "src/main/codex-accounts/legacy-shared-auth-migration.ts:json-stringify": 1, - "src/main/codex-accounts/legacy-shared-auth-migration.ts:read-file": 1, "src/main/codex-accounts/runtime-home-service.ts:json-parse": 2, "src/main/codex-accounts/runtime-home-service.ts:json-stringify": 4, "src/main/codex-accounts/runtime-home-service.ts:read-file": 20, @@ -226,35 +161,30 @@ "src/main/codex-usage/store.ts:json-parse": 1, "src/main/codex-usage/store.ts:json-stringify": 3, "src/main/codex-usage/store.ts:read-file": 1, - "src/main/codex/codex-app-server-grant-bridge.ts:json-parse": 1, "src/main/codex/codex-app-server-grant-bridge.ts:json-stringify": 1, "src/main/codex/codex-app-server-grant-entry.ts:json-parse": 1, "src/main/codex/codex-app-server-grant-entry.ts:json-stringify": 4, + "src/main/codex/codex-app-server-grant-json.ts:json-parse": 1, "src/main/codex/codex-app-server-session.ts:json-parse": 1, "src/main/codex/codex-app-server-session.ts:json-stringify": 1, "src/main/codex/codex-home-paths.ts:json-parse": 1, "src/main/codex/codex-home-paths.ts:json-stringify": 1, - "src/main/codex/codex-home-paths.ts:read-file": 2, "src/main/codex/codex-hook-identity.ts:json-stringify": 1, - "src/main/codex/codex-real-home-hook-install.ts:read-file": 1, "src/main/codex/codex-session-backfill-audit.ts:json-stringify": 1, "src/main/codex/codex-session-backfill-marker.ts:json-parse": 1, "src/main/codex/codex-session-backfill-marker.ts:json-stringify": 1, "src/main/codex/codex-session-backfill-marker.ts:read-file": 1, "src/main/codex/codex-session-bridge.ts:json-parse": 1, "src/main/codex/codex-session-bridge.ts:read-file": 1, - "src/main/codex/codex-session-index-heal-state.ts:json-parse": 2, + "src/main/codex/codex-session-index-heal-jsonl.ts:json-parse": 1, + "src/main/codex/codex-session-index-heal-state.ts:json-parse": 1, "src/main/codex/codex-session-index-heal-state.ts:json-stringify": 2, - "src/main/codex/codex-session-index-heal-state.ts:read-file": 2, - "src/main/codex/codex-trust-config-rollback.ts:read-file": 2, "src/main/codex/codex-trust-grant-ledger.ts:json-parse": 1, "src/main/codex/codex-trust-grant-ledger.ts:json-stringify": 1, "src/main/codex/codex-trust-grant-ledger.ts:read-file": 1, "src/main/codex/config-settings-baseline.ts:json-stringify": 1, "src/main/codex/config-toml-trust.ts:json-stringify": 1, - "src/main/codex/config-toml-trust.ts:read-file": 1, "src/main/codex/hook-service.ts:json-stringify": 1, - "src/main/codex/hook-service.ts:read-file": 3, "src/main/codex/hook-trust-promotion.ts:json-parse": 1, "src/main/codex/hook-trust-promotion.ts:json-stringify": 1, "src/main/codex/hook-trust-promotion.ts:read-file": 1, @@ -265,64 +195,43 @@ "src/main/computer/desktop-script-provider-test-harness.ts:json-parse": 1, "src/main/computer/desktop-script-provider-test-harness.ts:json-stringify": 1, "src/main/computer/macos-computer-use-permission-status.ts:json-parse": 1, - "src/main/computer/macos-computer-use-permission-status.ts:read-file": 1, "src/main/computer/macos-native-provider-client.ts:json-parse": 1, "src/main/computer/macos-native-provider-client.ts:json-stringify": 2, "src/main/crash-reporting/crash-report-store.ts:json-parse": 1, "src/main/crash-reporting/crash-report-store.ts:json-stringify": 1, "src/main/crash-reporting/crash-report-store.ts:read-file": 1, - "src/main/daemon/client.ts:json-parse": 1, - "src/main/daemon/client.ts:read-file": 1, - "src/main/daemon/daemon-entry.ts:read-file": 1, "src/main/daemon/daemon-file-log.ts:json-stringify": 1, - "src/main/daemon/daemon-health.ts:json-parse": 4, - "src/main/daemon/daemon-health.ts:read-file": 8, + "src/main/daemon/daemon-health.ts:json-parse": 2, + "src/main/daemon/daemon-health.ts:read-file": 4, "src/main/daemon/daemon-host-relocation.ts:json-parse": 1, "src/main/daemon/daemon-host-relocation.ts:json-stringify": 1, - "src/main/daemon/daemon-host-relocation.ts:read-file": 2, - "src/main/daemon/daemon-init.ts:read-file": 1, "src/main/daemon/daemon-init.ts:unbounded-fanout": 1, "src/main/daemon/daemon-pty-adapter.ts:json-stringify": 1, "src/main/daemon/daemon-spawner.ts:json-parse": 1, "src/main/daemon/daemon-spawner.ts:json-stringify": 1, - "src/main/daemon/daemon-spawner.ts:read-file": 1, "src/main/daemon/daemon-stream-backlog-probe.ts:json-stringify": 2, "src/main/daemon/degraded-daemon-fallback-shutdown.ts:unbounded-fanout": 1, - "src/main/daemon/history-manager.ts:json-parse": 4, - "src/main/daemon/history-manager.ts:json-stringify": 3, - "src/main/daemon/history-manager.ts:read-file": 4, "src/main/daemon/ndjson.ts:json-parse": 1, "src/main/daemon/ndjson.ts:json-stringify": 1, "src/main/daemon/terminal-history-file-reader.ts:json-parse": 2, + "src/main/daemon/terminal-history-session-metadata.ts:json-stringify": 1, "src/main/daemon/terminal-host-session-shutdown.ts:unbounded-fanout": 1, - "src/main/devin/hook-config-json.ts:read-file": 1, "src/main/devin/hook-settings.ts:json-stringify": 1, "src/main/diagnostics/main-thread-churn-probe.ts:json-stringify": 2, "src/main/emulator/emulator-bridge.ts:unbounded-fanout": 1, "src/main/emulator/emulator-probe.ts:json-stringify": 2, "src/main/emulator/serve-sim-accessibility-tree.ts:json-parse": 1, - "src/main/emulator/serve-sim-accessibility-tree.ts:response-text": 1, "src/main/emulator/serve-sim-detached-session.ts:json-parse": 1, - "src/main/emulator/serve-sim-detached-session.ts:read-file": 1, "src/main/emulator/serve-sim-execution.ts:json-parse": 1, - "src/main/emulator/serve-sim-execution.ts:read-file": 1, "src/main/emulator/simctl-simulator-devices.ts:json-parse": 1, + "src/main/filesystem-provider-bounded-text.ts:read-file": 1, "src/main/ghostty/index.ts:json-stringify": 2, - "src/main/ghostty/index.ts:read-file": 1, - "src/main/ghostty/theme-resolution.ts:read-file": 1, - "src/main/git/huge-folder-ignore.ts:read-file": 1, - "src/main/git/repo.ts:read-file": 3, - "src/main/git/status.ts:read-file": 2, "src/main/git/worktree-include-file.ts:read-file": 1, "src/main/git/worktree.ts:read-file": 2, - "src/main/gitea/client.ts:response-text": 1, "src/main/gitea/pull-request-creation.ts:json-stringify": 1, "src/main/github/client.ts:json-parse": 37, - "src/main/github/client.ts:read-file": 2, "src/main/github/conflict-summary-cache.ts:json-stringify": 1, "src/main/github/issues.ts:json-parse": 7, - "src/main/github/local-git-config-signature.ts:read-file": 3, - "src/main/github/local-git-config-signature.ts:unbounded-fanout": 1, "src/main/github/merged-pr-commit-membership.ts:json-parse": 1, "src/main/github/project-view.ts:json-parse": 1, "src/main/github/project-view/internals.ts:json-parse": 2, @@ -335,83 +244,58 @@ "src/main/gitlab/issues.ts:json-parse": 5, "src/main/gitlab/merge-request-creation-lookup.ts:json-parse": 2, "src/main/gitlab/merge-request-creation-lookup.ts:json-stringify": 1, - "src/main/gitlab/merge-request-creation.ts:read-file": 2, "src/main/gitlab/work-item-details.ts:json-parse": 8, "src/main/hermes/hook-service.ts:json-stringify": 1, - "src/main/hermes/hook-service.ts:read-file": 4, - "src/main/hooks.ts:read-file": 4, - "src/main/ipc/ai-vault.ts:json-stringify": 1, + "src/main/integration-fanout.ts:unbounded-fanout": 1, + "src/main/ipc/ai-vault-host-scan-batches.ts:unbounded-fanout": 1, "src/main/ipc/app.ts:json-parse": 1, "src/main/ipc/crash-reporting.ts:json-stringify": 2, "src/main/ipc/developer-permissions.ts:unbounded-fanout": 1, "src/main/ipc/ephemeral-vm-runtime-handlers.ts:json-stringify": 1, "src/main/ipc/feedback.ts:json-stringify": 1, - "src/main/ipc/filesystem-mutations.ts:read-file": 1, "src/main/ipc/filesystem-watcher.ts:json-stringify": 1, - "src/main/ipc/filesystem-watcher.ts:unbounded-fanout": 4, - "src/main/ipc/filesystem.ts:read-file": 4, - "src/main/ipc/filesystem.ts:unbounded-fanout": 1, + "src/main/ipc/filesystem.ts:read-file": 1, "src/main/ipc/notebook.ts:json-stringify": 2, "src/main/ipc/notification-authorization-status.ts:json-parse": 1, - "src/main/ipc/notifications.ts:read-file": 1, "src/main/ipc/pet.ts:json-parse": 1, - "src/main/ipc/pet.ts:read-file": 3, "src/main/ipc/preflight.ts:unbounded-fanout": 1, "src/main/ipc/pty-management.ts:unbounded-fanout": 2, - "src/main/ipc/pty.ts:unbounded-fanout": 1, "src/main/ipc/remote-workspace.ts:unbounded-fanout": 1, "src/main/ipc/repos.ts:read-file": 1, - "src/main/ipc/shell.ts:read-file": 1, "src/main/ipc/ssh-worktree-create-root-registration.ts:unbounded-fanout": 1, "src/main/ipc/ssh.ts:json-stringify": 1, "src/main/ipc/ssh.ts:unbounded-fanout": 1, "src/main/ipc/terminal-render-desync-evidence.ts:json-stringify": 1, "src/main/ipc/watcher-removal-gate.ts:json-stringify": 1, - "src/main/ipc/workspace-cleanup-activity.ts:read-file": 1, "src/main/ipc/workspace-ports.ts:json-stringify": 1, - "src/main/ipc/worktree-base-directory-poller.ts:unbounded-fanout": 3, "src/main/ipc/worktree-base-directory-watch-targets.ts:read-file": 1, - "src/main/ipc/worktree-common-git-directory.ts:read-file": 1, - "src/main/ipc/worktree-git-common-polling.ts:unbounded-fanout": 3, - "src/main/ipc/worktree-head-identity-reader.ts:read-file": 1, - "src/main/ipc/worktree-remote.ts:read-file": 1, + "src/main/ipc/worktree-git-common-snapshot.ts:unbounded-fanout": 2, "src/main/ipc/worktrees.ts:read-file": 8, "src/main/jira/client.ts:json-parse": 1, - "src/main/jira/client.ts:json-stringify": 1, - "src/main/jira/client.ts:read-file": 2, - "src/main/jira/client.ts:response-text": 3, "src/main/jira/issues.ts:json-stringify": 6, - "src/main/jira/issues.ts:unbounded-fanout": 2, "src/main/keybindings/keybinding-file.ts:json-parse": 1, "src/main/keybindings/keybinding-file.ts:json-stringify": 1, "src/main/keybindings/keybinding-file.ts:read-file": 1, - "src/main/kimi/hook-service.ts:read-file": 2, "src/main/linear/client.ts:json-parse": 2, - "src/main/linear/client.ts:json-stringify": 1, - "src/main/linear/client.ts:read-file": 3, "src/main/linear/issue-context-client.ts:unbounded-fanout": 2, "src/main/linear/issues.ts:unbounded-fanout": 2, "src/main/linear/mcp-issue-list.ts:unbounded-fanout": 1, - "src/main/linear/projects.ts:unbounded-fanout": 1, "src/main/linear/teams.ts:unbounded-fanout": 3, - "src/main/local-worktree-filesystem.ts:read-file": 1, "src/main/minimax/minimax-cookie-store.ts:read-file": 1, "src/main/native-chat/transcript-line-decoders-grok.ts:json-parse": 1, "src/main/native-chat/transcript-record-blocks.ts:json-stringify": 1, "src/main/observability/bundle.ts:json-parse": 1, "src/main/observability/bundle.ts:json-stringify": 2, - "src/main/observability/bundle.ts:read-file": 1, "src/main/observability/diagnostic-upload-http.ts:json-parse": 1, "src/main/observability/diagnostic-upload-http.ts:json-stringify": 1, "src/main/observability/local-file-sink.ts:json-stringify": 1, "src/main/opencode-usage/scanner.ts:json-parse": 1, - "src/main/opencode-usage/scanner.ts:json-stringify": 1, + "src/main/opencode-usage/sqlite-usage-row-stream.ts:json-stringify": 1, "src/main/opencode-usage/store.ts:json-parse": 1, "src/main/opencode-usage/store.ts:json-stringify": 3, "src/main/opencode-usage/store.ts:read-file": 1, - "src/main/opencode/hook-service.ts:json-parse": 1, - "src/main/opencode/hook-service.ts:json-stringify": 2, - "src/main/opencode/hook-service.ts:read-file": 2, + "src/main/opencode/config-overlay-manifest.ts:json-parse": 1, + "src/main/opencode/hook-service.ts:json-stringify": 1, "src/main/orca-profiles/profile-cloud-client.ts:json-stringify": 1, "src/main/orca-profiles/profile-cloud-client.ts:response-text": 1, "src/main/orca-profiles/profile-cloud-org-members-client.ts:json-stringify": 1, @@ -432,72 +316,50 @@ "src/main/persistence.ts:read-file": 3, "src/main/pi/agent-status-extension-source.ts:json-stringify": 1, "src/main/pi/agent-status-extension-source.ts:read-file": 2, - "src/main/pi/titlebar-extension-service.ts:read-file": 1, "src/main/ports/local-workspace-port-scanner.ts:json-parse": 1, - "src/main/ports/local-workspace-port-scanner.ts:read-file": 2, "src/main/project-groups/nested-repo-discovery.ts:read-file": 1, "src/main/providers/pty-process-list-admission.ts:unbounded-fanout": 2, "src/main/providers/ssh-filesystem-provider.ts:read-file": 3, "src/main/providers/ssh-git-provider.ts:json-stringify": 1, "src/main/providers/types.ts:read-file": 1, "src/main/providers/windows-foreground-process-rows.ts:json-parse": 1, - "src/main/pty/shell-startup-env.ts:read-file": 1, + "src/main/pty/config-overlay-mirroring.ts:json-stringify": 1, "src/main/quit-teardown-deadline.ts:unbounded-fanout": 1, "src/main/rate-limits/claude-fetcher.ts:json-parse": 1, - "src/main/rate-limits/claude-fetcher.ts:read-file": 2, - "src/main/rate-limits/claude-fetcher.ts:response-text": 1, - "src/main/rate-limits/claude-oauth-usage-error.ts:response-text": 1, "src/main/rate-limits/codex-fetcher.ts:json-parse": 2, "src/main/rate-limits/codex-fetcher.ts:json-stringify": 3, - "src/main/rate-limits/codex-fetcher.ts:read-file": 1, - "src/main/rate-limits/codex-fetcher.ts:response-text": 3, "src/main/rate-limits/gemini-cli-oauth-extractor.ts:json-parse": 1, - "src/main/rate-limits/gemini-cli-oauth-extractor.ts:read-file": 2, "src/main/rate-limits/gemini-oauth-sources.ts:json-parse": 2, "src/main/rate-limits/gemini-oauth-sources.ts:json-stringify": 2, - "src/main/rate-limits/gemini-oauth-sources.ts:read-file": 2, - "src/main/rate-limits/gemini-oauth-sources.ts:response-text": 2, "src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts:json-stringify": 1, "src/main/rate-limits/gemini-usage-fetcher.ts:json-stringify": 1, - "src/main/rate-limits/gemini-usage-fetcher.ts:response-text": 1, "src/main/rate-limits/grok-auth.ts:json-parse": 1, - "src/main/rate-limits/grok-auth.ts:read-file": 1, - "src/main/rate-limits/grok-fetcher.ts:response-text": 1, "src/main/rate-limits/kimi-fetcher.ts:json-parse": 1, - "src/main/rate-limits/kimi-fetcher.ts:read-file": 1, - "src/main/rate-limits/kimi-fetcher.ts:response-text": 1, - "src/main/rate-limits/minimax-fetcher.ts:response-text": 1, "src/main/rate-limits/minimax-request-context.ts:unbounded-fanout": 1, - "src/main/rate-limits/opencode-go-usage-fetcher.ts:response-text": 2, "src/main/remote-agent-trust-presets.ts:json-parse": 1, "src/main/remote-agent-trust-presets.ts:json-stringify": 2, "src/main/remote-agent-trust-presets.ts:read-file": 1, "src/main/repo-icon-autodetect.ts:json-parse": 2, - "src/main/repo-icon-autodetect.ts:read-file": 6, + "src/main/repo-icon-autodetect.ts:read-file": 1, + "src/main/repo-icon-file-detection.ts:read-file": 2, "src/main/runtime/agent-session-claim-identity.ts:read-file": 2, "src/main/runtime/claude-agent-teams-shim-env.ts:read-file": 1, "src/main/runtime/device-registry.ts:json-parse": 1, - "src/main/runtime/device-registry.ts:read-file": 1, "src/main/runtime/e2ee-keypair.ts:json-parse": 1, "src/main/runtime/e2ee-keypair.ts:read-file": 1, "src/main/runtime/orca-runtime-files.ts:json-stringify": 1, - "src/main/runtime/orca-runtime-files.ts:read-file": 4, + "src/main/runtime/orca-runtime-files.ts:read-file": 2, "src/main/runtime/orca-runtime-files.ts:unbounded-fanout": 1, "src/main/runtime/orca-runtime.ts:json-stringify": 3, - "src/main/runtime/orca-runtime.ts:read-file": 8, - "src/main/runtime/orca-runtime.ts:unbounded-fanout": 2, - "src/main/runtime/orchestration/coordinator.ts:json-parse": 2, - "src/main/runtime/orchestration/db.ts:json-parse": 2, + "src/main/runtime/orca-runtime.ts:read-file": 1, + "src/main/runtime/orca-runtime.ts:unbounded-fanout": 1, "src/main/runtime/orchestration/db.ts:json-stringify": 3, "src/main/runtime/orchestration/lifecycle-reconciliation.ts:json-parse": 1, "src/main/runtime/orchestration/lifecycle-reconciliation.ts:json-stringify": 1, - "src/main/runtime/relay/relay-control-client.ts:json-stringify": 4, + "src/main/runtime/orchestration/query-retention.ts:json-parse": 1, "src/main/runtime/relay/relay-control-protocol.ts:json-parse": 1, "src/main/runtime/relay/relay-http-client.ts:json-stringify": 2, - "src/main/runtime/relay/relay-http-client.ts:response-text": 2, "src/main/runtime/relay/relay-revoke-outbox.ts:json-parse": 1, - "src/main/runtime/relay/relay-revoke-outbox.ts:read-file": 1, - "src/main/runtime/rpc/dispatcher.ts:json-stringify": 6, "src/main/runtime/rpc/e2ee-channel.ts:json-stringify": 4, "src/main/runtime/rpc/methods/orchestration-gates.ts:json-parse": 1, "src/main/runtime/rpc/methods/orchestration.ts:json-parse": 1, @@ -505,51 +367,33 @@ "src/main/runtime/rpc/relay-transport.ts:json-stringify": 1, "src/main/runtime/rpc/ws-fallback-port-store.ts:json-parse": 1, "src/main/runtime/rpc/ws-fallback-port-store.ts:json-stringify": 1, - "src/main/runtime/rpc/ws-fallback-port-store.ts:read-file": 1, "src/main/runtime/runtime-metadata.ts:json-parse": 1, "src/main/runtime/runtime-metadata.ts:read-file": 1, - "src/main/runtime/runtime-rpc.ts:json-parse": 4, + "src/main/runtime/runtime-rpc.ts:json-parse": 2, "src/main/runtime/runtime-rpc.ts:json-stringify": 11, "src/main/runtime/runtime-rpc.ts:unbounded-fanout": 2, "src/main/runtime/tls-certificate.ts:read-file": 4, "src/main/runtime/windows-mobile-firewall.ts:json-parse": 2, "src/main/runtime/worktree-teardown.ts:unbounded-fanout": 1, - "src/main/serve-update-handoff.ts:json-parse": 2, - "src/main/serve-update-handoff.ts:json-stringify": 1, - "src/main/serve-update-handoff.ts:read-file": 2, "src/main/server/serve-readiness.ts:json-stringify": 2, "src/main/skills/claude-plugin-skill-sources.ts:json-parse": 1, - "src/main/skills/discovery.ts:unbounded-fanout": 2, "src/main/skills/skill-bundle-artifacts.ts:read-file": 3, "src/main/skills/skill-freshness-inventory.ts:unbounded-fanout": 1, "src/main/skills/skill-package-identity.ts:json-stringify": 1, - "src/main/source-control/hosted-review-api-request.ts:response-text": 2, - "src/main/source-control/pull-request-template.ts:read-file": 2, + "src/main/source-control/pull-request-template.ts:read-file": 1, "src/main/speech/openai-api-key-store.ts:json-parse": 1, "src/main/speech/openai-api-key-store.ts:read-file": 2, - "src/main/speech/openai-transcription-client.ts:response-text": 1, "src/main/speech/stt-worker.ts:json-parse": 3, - "src/main/ssh/relay-protocol.ts:json-parse": 1, - "src/main/ssh/relay-protocol.ts:json-stringify": 1, - "src/main/ssh/ssh-agent-identity-filter.ts:read-file": 1, - "src/main/ssh/ssh-auth-resolution.ts:read-file": 2, - "src/main/ssh/ssh-config-include-expander.ts:read-file": 1, + "src/main/ssh/sftp-namespace-resolution.ts:json-stringify": 2, "src/main/ssh/ssh-control-socket.ts:json-stringify": 1, - "src/main/ssh/ssh-git-response-stream-reader.ts:json-parse": 1, - "src/main/ssh/ssh-port-forward.ts:unbounded-fanout": 1, + "src/main/ssh/ssh-git-response-stream-assembler.ts:json-parse": 1, "src/main/ssh/ssh-relay-deploy.ts:json-stringify": 2, + "src/main/ssh/ssh-relay-install-namespace.ts:json-stringify": 1, "src/main/ssh/ssh-relay-session.ts:unbounded-fanout": 1, - "src/main/ssh/ssh-relay-versioned-install.ts:read-file": 1, "src/main/ssh/ssh-remote-cli-format.ts:json-stringify": 1, - "src/main/ssh/ssh-remote-commands.ts:read-file": 1, "src/main/ssh/ssh-remote-orca-cli.ts:json-stringify": 3, "src/main/ssh/ssh-remote-orchestration-send.ts:json-stringify": 1, "src/main/ssh/ssh-remote-platform.ts:json-stringify": 2, - "src/main/ssh/system-ssh-file-transfer.ts:json-stringify": 1, - "src/main/ssh/system-ssh-file-transfer.ts:read-file": 1, - "src/main/startup/configure-process.ts:json-parse": 1, - "src/main/startup/configure-process.ts:read-file": 1, - "src/main/startup/ensure-virtual-display.ts:read-file": 1, "src/main/startup/gpu-fallback-marker.ts:json-parse": 1, "src/main/startup/gpu-fallback-marker.ts:json-stringify": 1, "src/main/startup/gpu-fallback-marker.ts:read-file": 1, @@ -557,53 +401,25 @@ "src/main/startup/windows-user-data-acl.ts:json-parse": 1, "src/main/startup/windows-user-data-acl.ts:json-stringify": 1, "src/main/startup/windows-user-data-acl.ts:read-file": 1, - "src/main/stats/collector.ts:json-parse": 1, - "src/main/stats/collector.ts:json-stringify": 1, - "src/main/stats/collector.ts:read-file": 1, + "src/main/stats/stats-retention.ts:json-parse": 1, "src/main/system-fonts.ts:json-parse": 1, "src/main/telemetry/consent.ts:json-stringify": 1, - "src/main/terminal-history.ts:json-parse": 1, + "src/main/terminal-history-gc.ts:json-parse": 1, "src/main/terminal-history.ts:json-stringify": 1, - "src/main/terminal-history.ts:read-file": 1, - "src/main/updater-changelog.ts:response-text": 1, + "src/main/text-generation/commit-message-text-generation.ts:json-stringify": 1, "src/main/updater-lifecycle-diagnostics.ts:json-stringify": 1, - "src/main/updater-nudge.ts:response-text": 1, - "src/main/updater-prerelease-feed.ts:response-text": 2, "src/main/updater-prerelease-feed.ts:unbounded-fanout": 2, "src/main/warp-themes/index.ts:read-file": 1, - "src/main/window/clipboard-remote-file-copy.ts:unbounded-fanout": 1, - "src/main/workspace-space-analysis.ts:unbounded-fanout": 4, - "src/main/worktree-removal-safety.ts:read-file": 1, "src/relay/agent-exec-handler.ts:json-stringify": 1, - "src/relay/external-automations-handler.ts:json-parse": 1, - "src/relay/external-automations-handler.ts:read-file": 3, - "src/relay/external-automations-handler.ts:unbounded-fanout": 1, - "src/relay/fs-handler-file-read.ts:read-file": 2, "src/relay/fs-handler.ts:json-stringify": 1, "src/relay/fs-handler.ts:read-file": 2, - "src/relay/fs-handler.ts:unbounded-fanout": 1, "src/relay/fs-stream-registry.ts:unbounded-fanout": 1, - "src/relay/git-handler-status-ops.ts:read-file": 1, - "src/relay/git-handler.ts:json-stringify": 1, - "src/relay/git-working-file-read.ts:read-file": 1, - "src/relay/plugin-overlay.ts:read-file": 1, - "src/relay/port-scan-handler.ts:read-file": 2, - "src/relay/preflight-handler.ts:unbounded-fanout": 1, - "src/relay/protocol.ts:json-parse": 2, - "src/relay/protocol.ts:json-stringify": 2, - "src/relay/pty-handler.ts:json-parse": 1, - "src/relay/pty-handler.ts:json-stringify": 1, + "src/relay/mobile-file-directory-reader.ts:unbounded-fanout": 1, + "src/relay/protocol.ts:json-stringify": 1, "src/relay/pty-handler.ts:unbounded-fanout": 1, - "src/relay/pty-shell-launch.ts:read-file": 1, - "src/relay/pty-shell-utils.ts:read-file": 1, - "src/relay/relay-handshake.ts:read-file": 1, - "src/relay/relay-watcher-removal-fence.ts:unbounded-fanout": 2, + "src/relay/pty-persistence-envelope.ts:json-parse": 1, "src/relay/windows-port-scan.ts:json-parse": 1, "src/relay/workspace-session-handler.ts:json-parse": 1, - "src/relay/workspace-session-handler.ts:json-stringify": 1, - "src/relay/workspace-session-handler.ts:read-file": 1, - "src/relay/workspace-space-scan.ts:unbounded-fanout": 3, - "src/relay/wsl-hook-fs-bridge.ts:read-file": 1, "src/renderer/src/App.tsx:unbounded-fanout": 1, "src/renderer/src/components/GitHubItemDialog.tsx:json-stringify": 2, "src/renderer/src/components/PullRequestPage.tsx:json-stringify": 2, @@ -618,12 +434,11 @@ "src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx:json-stringify": 1, "src/renderer/src/components/editor/CombinedDiffViewer.tsx:json-stringify": 2, "src/renderer/src/components/editor/IpynbViewer.tsx:json-stringify": 2, + "src/renderer/src/components/editor/MonacoEditor.tsx:json-stringify": 1, "src/renderer/src/components/editor/editor-autosave-controller.ts:unbounded-fanout": 3, - "src/renderer/src/components/editor/ipynb-parse.ts:json-parse": 2, + "src/renderer/src/components/editor/ipynb-parse.ts:json-parse": 1, "src/renderer/src/components/editor/ipynb-parse.ts:json-stringify": 1, "src/renderer/src/components/editor/markdown-document-list-request.ts:json-stringify": 1, - "src/renderer/src/components/editor/markdown-export-extract.ts:response-text": 2, - "src/renderer/src/components/editor/markdown-export-extract.ts:unbounded-fanout": 1, "src/renderer/src/components/editor/raw-markdown-html.ts:json-stringify": 1, "src/renderer/src/components/editor/rich-markdown-html-superscript-link.ts:json-parse": 1, "src/renderer/src/components/editor/useEditorPanelContentState.ts:json-stringify": 1, @@ -695,9 +510,7 @@ "src/renderer/src/components/task-page-cache-selectors.ts:json-stringify": 2, "src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts:unbounded-fanout": 1, "src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts:json-stringify": 1, - "src/renderer/src/hooks/useIpcEvents.ts:unbounded-fanout": 2, "src/renderer/src/hooks/useIssueMetadata.ts:unbounded-fanout": 1, - "src/renderer/src/lib/agent-hibernation-coordinator.ts:unbounded-fanout": 1, "src/renderer/src/lib/ai-vault-session-drag.ts:json-parse": 1, "src/renderer/src/lib/ai-vault-session-drag.ts:json-stringify": 1, "src/renderer/src/lib/app-font-family.ts:json-stringify": 1, @@ -705,19 +518,15 @@ "src/renderer/src/lib/execute-open-editor-path-move.ts:unbounded-fanout": 1, "src/renderer/src/lib/feature-education-telemetry.ts:json-parse": 2, "src/renderer/src/lib/feature-education-telemetry.ts:json-stringify": 2, - "src/renderer/src/lib/react-error-boundary-reporting.ts:json-stringify": 1, - "src/renderer/src/lib/repo-slug-index.ts:unbounded-fanout": 1, "src/renderer/src/lib/workspace-file-drag.ts:json-parse": 1, "src/renderer/src/lib/workspace-file-drag.ts:json-stringify": 1, "src/renderer/src/lib/workspace-port-actions.ts:json-stringify": 1, - "src/renderer/src/lib/workspace-session-host-persistence.ts:unbounded-fanout": 1, "src/renderer/src/lib/worktree-operation-generation.ts:json-stringify": 1, "src/renderer/src/lib/worktree-operation-route.ts:json-stringify": 2, "src/renderer/src/lib/worktree-runtime-owner-index.ts:json-stringify": 2, "src/renderer/src/runtime/runtime-file-client.ts:read-file": 3, "src/renderer/src/runtime/sync-runtime-graph.ts:json-stringify": 7, "src/renderer/src/runtime/web-session-intent-owner.ts:json-stringify": 1, - "src/renderer/src/runtime/web-session-tabs-sync.ts:unbounded-fanout": 2, "src/renderer/src/store/slices/github.ts:unbounded-fanout": 3, "src/renderer/src/store/slices/linear.ts:json-stringify": 1, "src/renderer/src/store/slices/pull-request-generation.ts:json-stringify": 1, @@ -729,15 +538,13 @@ "src/renderer/src/store/slices/terminals.ts:unbounded-fanout": 1, "src/renderer/src/store/slices/workspace-cleanup.ts:json-stringify": 3, "src/renderer/src/store/slices/workspace-cleanup.ts:unbounded-fanout": 2, + "src/renderer/src/web/web-local-storage-json.ts:json-parse": 1, "src/renderer/src/web/web-pairing.ts:json-parse": 1, - "src/renderer/src/web/web-preload-api.ts:json-parse": 3, - "src/renderer/src/web/web-preload-api.ts:json-stringify": 2, - "src/renderer/src/web/web-preload-api.ts:unbounded-fanout": 1, - "src/renderer/src/web/web-runtime-client.ts:json-parse": 3, - "src/renderer/src/web/web-runtime-client.ts:json-stringify": 3, + "src/renderer/src/web/web-runtime-client.ts:json-parse": 1, + "src/renderer/src/web/web-runtime-client.ts:json-stringify": 1, "src/renderer/src/web/web-runtime-client.ts:response-text": 1, - "src/renderer/src/web/web-runtime-environment.ts:json-parse": 1, - "src/renderer/src/web/web-runtime-environment.ts:json-stringify": 1, + "src/renderer/src/web/web-runtime-inbound-json.ts:json-parse": 1, + "src/renderer/src/web/web-runtime-outbound-json.ts:json-stringify": 1, "src/shared/agent-hook-listener.ts:json-parse": 1, "src/shared/agent-hook-listener.ts:json-stringify": 16, "src/shared/agent-question-answered-intent.ts:json-parse": 1, @@ -764,6 +571,7 @@ "src/shared/git-uncommitted-line-stats.ts:unbounded-fanout": 1, "src/shared/in-flight-promise-dedupe.ts:json-stringify": 1, "src/shared/linear-issue-attribute-filter.ts:json-stringify": 1, + "src/shared/linux-proc-port-scan-limits.ts:read-file": 2, "src/shared/map-with-concurrency.ts:unbounded-fanout": 2, "src/shared/mcp-config.ts:json-parse": 1, "src/shared/mobile-relay-pairing-fixtures.ts:json-stringify": 1, @@ -771,9 +579,11 @@ "src/shared/native-chat-tool-summary.ts:json-stringify": 2, "src/shared/pairing.ts:json-parse": 1, "src/shared/pairing.ts:json-stringify": 1, + "src/shared/persisted-state-file-bounds.ts:json-parse": 1, "src/shared/pr-checks-fix-prompt.ts:json-stringify": 2, "src/shared/pull-request-generation.ts:json-parse": 1, "src/shared/quick-open-readdir-walk.ts:unbounded-fanout": 1, + "src/shared/relay-json-admission.ts:json-parse": 1, "src/shared/remote-runtime-client.ts:json-stringify": 2, "src/shared/remote-runtime-request-frames.ts:json-parse": 1, "src/shared/remote-runtime-request-websocket.ts:json-stringify": 1, diff --git a/config/scripts/check-no-merge-base-revert.mjs b/config/scripts/check-no-merge-base-revert.mjs new file mode 100644 index 00000000000..b7bf57504ac --- /dev/null +++ b/config/scripts/check-no-merge-base-revert.mjs @@ -0,0 +1,150 @@ +// Guard: a branch must not drop work that is already reachable from its own merge base. +// +// Building a branch by checking out whole files from an older stack (instead of merging) silently +// reverts shipped fixes. Typecheck and tests cannot see it — a reverted feature still compiles, and +// its old tests still pass — so this compares, for every file both sides touched since the merge +// base, whether lines the base branch added are missing from the topic branch. +// +// Run: node config/scripts/check-no-merge-base-revert.mjs [--base origin/main] [--head HEAD] [--json] +import { execFileSync } from 'node:child_process' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const IGNORED_LINE = /^\s*(\/\/|\/\*|\*|#)?\s*$/ + +function git(root, args) { + return execFileSync('git', args, { cwd: root, maxBuffer: 1e9, encoding: 'utf8' }) +} + +function fileAt(root, ref, file) { + try { + return execFileSync('git', ['show', `${ref}:${file}`], { + cwd: root, + maxBuffer: 1e9, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + } catch { + return null + } +} + +// A line "survives" if it appears anywhere in the head version. Position-independent on purpose: +// the point is whether the work is still present, not whether it moved. +export function findDroppedLines(baseText, targetText, headText) { + if (baseText === null || targetText === null) { + return [] + } + const baseLines = new Set(baseText.split('\n').map((l) => l.trim())) + const headLines = new Set((headText ?? '').split('\n').map((l) => l.trim())) + const dropped = [] + for (const raw of targetText.split('\n')) { + const line = raw.trim() + if (IGNORED_LINE.test(line) || line.length < 8) { + continue + } + // Only consider lines the base branch ADDED after the merge base. + if (baseLines.has(line) || headLines.has(line)) { + continue + } + dropped.push(line) + } + return dropped +} + +export function analyze(root, baseRef, headRef) { + const mergeBase = git(root, ['merge-base', baseRef, headRef]).trim() + const changedByBase = new Set( + git(root, ['diff', '--name-only', mergeBase, baseRef]).split('\n').filter(Boolean) + ) + const changedByHead = new Set( + git(root, ['diff', '--name-only', mergeBase, headRef]).split('\n').filter(Boolean) + ) + const overlap = [...changedByBase].filter((f) => changedByHead.has(f)).sort() + + // Whole files the base branch ADDED that the topic branch lacks. Line-level comparison cannot see + // these — the file simply is not there — yet dropping one silently removes a shipped feature. + const missingFiles = [] + for (const file of git(root, ['diff', '--name-status', mergeBase, baseRef]).split('\n')) { + const [status, ...rest] = file.split('\t') + const target = rest.join('\t') + if (status !== 'A' || !target) { + continue + } + if (fileAt(root, headRef, target) === null) { + missingFiles.push(target) + } + } + + const reverts = [] + for (const file of overlap) { + const baseText = fileAt(root, mergeBase, file) + const targetText = fileAt(root, baseRef, file) + const headText = fileAt(root, headRef, file) + if (targetText === null) { + continue // deleted on the base branch; not a revert of added work + } + const dropped = findDroppedLines(baseText, targetText, headText) + if (dropped.length > 0) { + reverts.push({ file, droppedLines: dropped.length, sample: dropped.slice(0, 3) }) + } + } + reverts.sort((a, b) => b.droppedLines - a.droppedLines) + return { mergeBase, overlapCount: overlap.length, reverts, missingFiles } +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (invokedDirectly) { + const root = path.resolve(import.meta.dirname, '../..') + const argv = process.argv.slice(2) + const readArg = (name, fallback) => { + const i = argv.indexOf(name) + return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback + } + const baseRef = readArg('--base', 'origin/main') + const headRef = readArg('--head', 'HEAD') + const asJson = argv.includes('--json') + + const { mergeBase, overlapCount, reverts, missingFiles } = analyze(root, baseRef, headRef) + if (asJson) { + console.log(JSON.stringify({ mergeBase, overlapCount, reverts, missingFiles }, null, 2)) + } + if (missingFiles.length > 0) { + console.error( + `This branch is MISSING ${missingFiles.length} file(s) that ${baseRef} added since the merge base:\n` + ) + for (const f of missingFiles.slice(0, 25)) { + console.error(` ${f}`) + } + if (missingFiles.length > 25) { + console.error(` ... and ${missingFiles.length - 25} more`) + } + console.error('') + } + if (reverts.length > 0 || missingFiles.length > 0) { + if (reverts.length > 0) { + console.error( + `This branch drops work already present on ${baseRef} (merge base ${mergeBase.slice(0, 10)}).\n` + + `${reverts.length} of ${overlapCount} overlapping files lose lines ${baseRef} added:\n` + ) + for (const r of reverts.slice(0, 25)) { + console.error(` ${r.file} (-${r.droppedLines} lines)`) + for (const s of r.sample) { + console.error(` ${s.slice(0, 100)}`) + } + } + if (reverts.length > 25) { + console.error(` ... and ${reverts.length - 25} more`) + } + } + console.error( + `\nRebuild by MERGING onto ${baseRef} rather than overwriting.` + + `\nTypecheck and tests cannot catch this: a reverted feature still compiles and its old tests still pass.` + ) + process.exit(1) + } + console.log( + `no-merge-base-revert: clean (${overlapCount} overlapping files, 0 reverted, 0 missing)` + ) +} diff --git a/config/scripts/check-no-merge-base-revert.test.mjs b/config/scripts/check-no-merge-base-revert.test.mjs new file mode 100644 index 00000000000..2b2b7fb0d0f --- /dev/null +++ b/config/scripts/check-no-merge-base-revert.test.mjs @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { findDroppedLines } from './check-no-merge-base-revert.mjs' + +const base = ['const a = someValueHere', 'const b = anotherValueHere'].join('\n') +const withMainFix = [base, 'const shipped = mainAddedThisFix()'].join('\n') + +describe('check-no-merge-base-revert', () => { + it('flags a line the base branch added that the head branch lacks', () => { + const dropped = findDroppedLines(base, withMainFix, base) + expect(dropped).toEqual(['const shipped = mainAddedThisFix()']) + }) + + it('accepts a head branch that kept the added line', () => { + const head = [withMainFix, 'const bound = MAX_RETAINED_BYTES'].join('\n') + expect(findDroppedLines(base, withMainFix, head)).toEqual([]) + }) + + it('is position-independent — moved code still counts as present', () => { + const head = ['const shipped = mainAddedThisFix()', base].join('\n') + expect(findDroppedLines(base, withMainFix, head)).toEqual([]) + }) + + it('ignores whitespace and trivially short lines', () => { + const noisy = [base, '', ' ', '//', 'x++'].join('\n') + expect(findDroppedLines(base, noisy, base)).toEqual([]) + }) + + it('flags a dropped explanatory comment', () => { + // Losing a `// Why:` that documents a non-obvious invariant is real lost work — several of the + // reverts this guard was written for were exactly that. + const withComment = [base, '// Why: reused ids restart aligned at zero'].join('\n') + expect(findDroppedLines(base, withComment, base)).toEqual([ + '// Why: reused ids restart aligned at zero' + ]) + }) + + it('reports nothing when the base branch added nothing', () => { + expect(findDroppedLines(base, base, base)).toEqual([]) + }) + + it('treats a file absent on either side as no-revert rather than throwing', () => { + expect(findDroppedLines(null, withMainFix, base)).toEqual([]) + expect(findDroppedLines(base, null, base)).toEqual([]) + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 5d1d949131e..846a4e6d546 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -4,6 +4,9 @@ "../src/cli/**/*", "../src/shared/**/*", "../src/main/agent-state-file-reader.ts", + "../src/main/agent-hooks/agent-hook-file-comparison.ts", + "../src/main/agent-hooks/agent-hook-file-limits.ts", + "../src/main/agent-hooks/agent-hook-sftp-text-reader.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-config-write-path.ts", "../src/main/agent-hooks/hooks-json-read.ts", @@ -20,6 +23,7 @@ "../src/main/codex/codex-app-server-client.ts", "../src/main/codex/codex-app-server-grant-bridge.ts", "../src/main/codex/codex-app-server-grant-envelope.ts", + "../src/main/codex/codex-app-server-grant-json.ts", "../src/main/codex/codex-app-server-session.ts", "../src/main/codex/codex-config-mirror.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", @@ -27,6 +31,7 @@ "../src/main/codex/codex-config-settings-removal.ts", "../src/main/codex/codex-config-settings-upsert.ts", "../src/main/codex/codex-home-paths.ts", + "../src/main/codex/codex-host-retry-deadlines.ts", "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-hook-trust-grant.ts", "../src/main/codex/codex-managed-trust-reconciliation.ts", @@ -38,6 +43,7 @@ "../src/main/codex/codex-user-hook-trust-rebase-client.ts", "../src/main/codex/codex-user-hook-trust-rebase.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", + "../src/main/codex/codex-wsl-reconciliation-generations.ts", "../src/main/codex/config-settings-baseline.ts", "../src/main/codex/config-settings-conflict-resolution.ts", "../src/main/codex/config-settings-promotion.ts", @@ -52,12 +58,14 @@ "../src/main/codex-accounts/fs-utils.ts", "../src/main/codex-accounts/wsl-codex-command.ts", "../src/main/codex-cli/command.ts", + "../src/main/codex-cli/nvm-version-directory-discovery.ts", "../src/main/command-code/command-code-managed-script.ts", "../src/main/command-code/hook-service.ts", "../src/main/copilot/hook-service.ts", "../src/main/cursor/hook-service.ts", "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", + "../src/main/generated-node-bounded-file-reader.ts", "../src/main/grok/hook-service.ts", "../src/main/devin/hook-settings.ts", "../src/main/devin/hook-service.ts", diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8b236bfeaff..618545e3f7e 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -86,6 +86,10 @@ import { import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability' import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result' +import { + LAST_VISITED_WORKTREE_STORAGE_KEY, + serializeLastVisitedWorktreeRecord +} from '../../../../src/worktree/last-visited-worktree-repo' import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation' import { triggerMediumImpact, @@ -214,12 +218,6 @@ import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-m import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation' import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics' -import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects' -import type { - SessionTabsApplyOutcome, - SessionTabsStreamSource -} from '../../../../src/session/mobile-session-tabs-stream-health' -import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation' import { getRepoIdFromMobileWorktreeId, getActiveTabIdForHandle, @@ -233,6 +231,8 @@ import { TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND, updateTerminalCwdFromStreamEvent } from '../../../../src/session/mobile-session-route-helpers' +import { MobileSessionFileDocLifecycle } from '../../../../src/session/mobile-session-file-doc-lifecycle' +import { MobileSessionMarkdownDocLifecycle } from '../../../../src/session/mobile-session-markdown-doc-lifecycle' import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout' import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll' import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab' @@ -873,7 +873,6 @@ export default function SessionScreen() { const sessionTabsRef = useRef([]) // Why: track the last applied (epoch, version) so a late older snapshot can't overwrite a newer one and resurrect closed tabs (session-tab-snapshot-gate). const appliedSnapshotMarkerRef = useRef({ epoch: null, version: -1 }) - const appliedSessionTabsRevisionRef = useRef(0) // Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back. const closedTabTombstonesRef = useRef>(new Map()) const [terminalsLoaded, setTerminalsLoaded] = useState(false) @@ -904,7 +903,9 @@ export default function SessionScreen() { const tabLayoutsRef = useRef>(new Map()) const [markdownDocs, setMarkdownDocs] = useState>(new Map()) const markdownDocsRef = useRef>(new Map()) + const markdownDocLifecycleRef = useRef(new MobileSessionMarkdownDocLifecycle()) const [fileDocs, setFileDocs] = useState>(new Map()) + const fileDocLifecycleRef = useRef(new MobileSessionFileDocLifecycle()) const [diffComments, setDiffComments] = useState([]) const diffCommentsRef = useRef([]) const [diffCommentBusy, setDiffCommentBusy] = useState(false) @@ -1697,13 +1698,12 @@ export default function SessionScreen() { ) const applySessionTabs = useCallback( - (result: SessionTabsResult): SessionTabsApplyOutcome => { + (result: SessionTabsResult) => { const diagnostics = terminalDiagnosticsRef.current // Reject stale snapshots; suppress just-closed tabs until the publisher confirms absence — see session-tab-snapshot-gate. if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) { - return { accepted: false } + return } - const applicationRevision = ++appliedSessionTabsRevisionRef.current let nextTabs = applyClosedTabTombstones( result.tabs, closedTabTombstonesRef.current, @@ -1729,6 +1729,8 @@ export default function SessionScreen() { if (orphanedDraftTabs.length > 0) { nextTabs = [...orphanedDraftTabs, ...nextTabs] } + markdownDocLifecycleRef.current.reconcile(nextTabs, setMarkdownDocs) + fileDocLifecycleRef.current.reconcile(nextTabs, setFileDocs) sessionTabsRef.current = nextTabs // Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop. setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs)) @@ -1748,11 +1750,6 @@ export default function SessionScreen() { terminalTabs.length ) setTerminalsLoaded(true) - const outcome = { - accepted: true as const, - effectiveTabs: nextTabs, - applicationRevision - } const snapshotActive = nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current @@ -1805,7 +1802,7 @@ export default function SessionScreen() { activeSessionTabTypeRef.current = 'terminal' setActiveHandle(pendingActiveTerminalHandle) subscribeToTerminal(pendingActiveTerminalHandle) - return outcome + return } else { pendingActiveTerminalHandleRef.current = null } @@ -1823,7 +1820,7 @@ export default function SessionScreen() { } activeHandleRef.current = null setActiveHandle(null) - return outcome + return } const previous = activeHandleRef.current if (previous && previous !== active.terminal) { @@ -1842,7 +1839,6 @@ export default function SessionScreen() { activeHandleRef.current = null setActiveHandle(null) } - return outcome }, [defaultTerminalHandlesToLiveInput, subscribeToTerminal, unsubscribeTerminal] ) @@ -1852,8 +1848,7 @@ export default function SessionScreen() { if (!client) { return } - setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' })) - try { + await markdownDocLifecycleRef.current.load(tab, setMarkdownDocs, async () => { const response = await client.sendRequest('markdown.readTab', { worktree: `id:${worktreeId}`, tabId: tab.id @@ -1866,19 +1861,16 @@ export default function SessionScreen() { editable?: boolean readOnlyReason?: string } - setMarkdownDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - content: result.content, - localContent: result.content, - baseVersion: result.version, - isDirty: false, - editable: result.editable === true, - stale: result.isDirty, - readOnlyReason: result.readOnlyReason - }) - ) - return + return { + status: 'ready', + content: result.content, + localContent: result.content, + baseVersion: result.version, + isDirty: false, + editable: result.editable === true, + stale: result.isDirty, + readOnlyReason: result.readOnlyReason + } } if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) { throw new Error((response as RpcFailure).error.message) @@ -1896,24 +1888,12 @@ export default function SessionScreen() { truncated: boolean byteLength: number } - setMarkdownDocs((prev) => - new Map(prev).set( - tab.id, - buildMarkdownDiskFallbackDoc({ - content: fileResult.content, - truncated: fileResult.truncated, - tabIsDirty: tab.isDirty - }) - ) - ) - } catch { - setMarkdownDocs((prev) => - new Map(prev).set(tab.id, { - status: 'error', - message: "Couldn't load markdown" - }) - ) - } + return buildMarkdownDiskFallbackDoc({ + content: fileResult.content, + truncated: fileResult.truncated, + tabIsDirty: tab.isDirty + }) + }) }, [client, worktreeId] ) @@ -1923,31 +1903,13 @@ export default function SessionScreen() { if (!client) { return } - setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' })) - try { - const doc = await resolveMobileFileTabDoc(client, { + await fileDocLifecycleRef.current.load(tab, setFileDocs, () => + resolveMobileFileTabDoc(client, { worktreeId, relativePath: tab.relativePath, diffSource: tab.diffSource }) - setFileDocs((prev) => new Map(prev).set(tab.id, doc)) - } catch (err) { - const message = err instanceof Error ? err.message : '' - const previewMessage = - message === 'binary_file' - ? 'Binary preview unavailable' - : message === 'file_too_large' - ? 'File too large for mobile preview' - : tab.diffSource === 'staged' || tab.diffSource === 'unstaged' - ? "Couldn't load diff preview" - : "Couldn't load file preview" - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'error', - message: previewMessage - }) - ) - } + ) }, [client, worktreeId] ) @@ -2262,75 +2224,56 @@ export default function SessionScreen() { }) } finally { markdownSaveInFlightRef.current.delete(tab.id) + if (markdownSaveSeqRef.current.get(tab.id) === saveSeq) { + markdownSaveSeqRef.current.delete(tab.id) + } } }, [client, markdownDocs, showToast, worktreeId] ) - const consumeAcceptedSessionTabs = useCallback( - ( - _result: SessionTabsResult, - effectiveTabs: readonly MobileSessionTab[], - source: SessionTabsStreamSource - ): void => { - runAcceptedMobileSessionTabsEffects({ - effectiveTabs, - source, - getPendingBrowserPageId: () => pendingBrowserFocusPageIdRef.current, - clearPendingBrowserPageId: (pageId) => { - if (pendingBrowserFocusPageIdRef.current === pageId) { - pendingBrowserFocusPageIdRef.current = null - } - }, - activateBrowserTab: (tab) => switchSessionTabRef.current?.(tab), - markActiveMarkdownStale: (tabId) => { - setMarkdownDocs((prev) => { - const current = prev.get(tabId) - if (current?.status !== 'ready' || current.isDirty) { - return prev - } - return new Map(prev).set(tabId, { ...current, stale: true }) - }) - } - }) - }, - [] - ) - const hasSessionTabsRecoveryNeed = useCallback( - () => closedTabTombstonesRef.current.size > 0 || pendingBrowserFocusPageIdRef.current !== null, - [] - ) - const getSessionTabsApplicationRevision = useCallback( - () => appliedSessionTabsRevisionRef.current, - [] - ) - const reportSessionTabsFetchStarted = useCallback(() => { + const fetchSessionTabsInFlightRef = useRef(false) + + const fetchSessionTabs = useCallback(async () => { + if (!client) { + terminalDiagnosticsRef.current.tabsFetchSkipped('no-client') + return + } + if (fetchSessionTabsInFlightRef.current) { + terminalDiagnosticsRef.current.tabsFetchSkipped('already-in-flight') + return + } + fetchSessionTabsInFlightRef.current = true terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId) - }, [worktreeId]) - const reportSessionTabsFetchSucceeded = useCallback((result: SessionTabsResult) => { - terminalDiagnosticsRef.current.tabsFetchSucceeded(result) - }, []) - const reportSessionTabsFetchFailed = useCallback((code: string) => { - terminalDiagnosticsRef.current.tabsFetchFailed(code) - }, []) - const reportSessionTabsFetchErrored = useCallback((error: unknown) => { - terminalDiagnosticsRef.current.tabsFetchErrored(error) - }, []) - const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } = - useMobileSessionTabsReconciliation({ - client, - connState, - worktreeId, - applySessionTabs, - consumeAcceptedSessionTabs, - fetchTerminals, - hasRecoveryNeed: hasSessionTabsRecoveryNeed, - getApplicationRevision: getSessionTabsApplicationRevision, - onFetchStarted: reportSessionTabsFetchStarted, - onFetchSucceeded: reportSessionTabsFetchSucceeded, - onFetchFailed: reportSessionTabsFetchFailed, - onFetchErrored: reportSessionTabsFetchErrored - }) + try { + const response = await client.sendRequest('session.tabs.list', { + worktree: `id:${worktreeId}` + }) + if (!response.ok) { + terminalDiagnosticsRef.current.tabsFetchFailed((response as RpcFailure).error.code) + return + } + const result = (response as RpcSuccess).result as SessionTabsResult + terminalDiagnosticsRef.current.tabsFetchSucceeded(result) + applySessionTabs(result) + // Focus a just-opened browser tab when it appears, via the normal activate path so it sticks yet stays switchable. + const pendingPageId = pendingBrowserFocusPageIdRef.current + if (pendingPageId) { + const browserTab = result.tabs.find( + (tab) => tab.type === 'browser' && tab.browserPageId === pendingPageId + ) + if (browserTab) { + pendingBrowserFocusPageIdRef.current = null + switchSessionTabRef.current?.(browserTab) + } + } + } catch (error) { + terminalDiagnosticsRef.current.tabsFetchErrored(error) + // Keep the last tab snapshot visible during reconnect/backoff. + } finally { + fetchSessionTabsInFlightRef.current = false + } + }, [applySessionTabs, client, worktreeId]) useEffect(() => { if (connState === 'connected') { @@ -2556,10 +2499,10 @@ export default function SessionScreen() { useEffect(() => { if (hostId && worktreeId) { - void AsyncStorage.setItem( - 'orca:last-visited-worktree', - JSON.stringify({ hostId, worktreeId }) - ) + const serialized = serializeLastVisitedWorktreeRecord({ hostId, worktreeId }) + if (serialized) { + void AsyncStorage.setItem(LAST_VISITED_WORKTREE_STORAGE_KEY, serialized) + } } }, [hostId, worktreeId]) @@ -2593,6 +2536,10 @@ export default function SessionScreen() { terminalDiagnosticsRef.current.resetRoute() appliedSnapshotMarkerRef.current = { epoch: null, version: -1 } closedTabTombstonesRef.current.clear() + markdownDocLifecycleRef.current.reset() + fileDocLifecycleRef.current.reset() + markdownSaveSeqRef.current.clear() + markdownSaveInFlightRef.current.clear() for (const queued of terminalGestureInputQueuesRef.current.values()) { if (queued.timer) { clearTimeout(queued.timer) @@ -2612,6 +2559,10 @@ export default function SessionScreen() { return () => { sessionTabActionSheetRequestSeqRef.current += 1 sessionTabActionSheetKeyboardHideSubRef.current?.remove() + markdownDocLifecycleRef.current.reset() + fileDocLifecycleRef.current.reset() + markdownSaveSeqRef.current.clear() + markdownSaveInFlightRef.current.clear() clearPendingLiveInputCommit() clearDelayedActionTimers() } @@ -2661,7 +2612,7 @@ export default function SessionScreen() { if (disposed) { return } - await ensureSessionTabs().catch(() => null) + await fetchSessionTabs().catch(() => null) if (disposed) { return } @@ -2704,13 +2655,72 @@ export default function SessionScreen() { client, connState, created, + fetchSessionTabs, fetchTerminals, - ensureSessionTabs, isFloatingWorkspaceRoute, showToast, worktreeId ]) + useEffect(() => { + if (!client || connState !== 'connected') { + return + } + const unsubscribe = client.subscribe( + 'session.tabs.subscribe', + { worktree: `id:${worktreeId}` }, + (payload) => { + const event = payload as { type?: string } & SessionTabsResult + if (event.type === 'snapshot' || event.type === 'updated') { + applySessionTabs(event) + const activeMarkdown = event.tabs.find( + (tab): tab is Extract => + tab.type === 'markdown' && tab.isActive + ) + if (activeMarkdown) { + setMarkdownDocs((prev) => { + const current = prev.get(activeMarkdown.id) + if (current?.status === 'ready' && activeMarkdown.isDirty && !current.isDirty) { + const next = new Map(prev) + next.set(activeMarkdown.id, { ...current, stale: true }) + return next + } + return prev + }) + } + } + } + ) + return () => unsubscribe() + }, [applySessionTabs, client, connState, worktreeId]) + + useFocusEffect( + useCallback(() => { + if (connState !== 'connected') { + return + } + const refreshOnForeground = () => { + if (AppState.currentState !== 'active') { + return + } + void fetchSessionTabs() + void fetchTerminals() + } + const appStateSubscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + refreshOnForeground() + } + }) + // Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden or backgrounded. + const interval = setInterval(refreshOnForeground, 2000) + refreshOnForeground() + return () => { + clearInterval(interval) + appStateSubscription.remove() + } + }, [connState, fetchSessionTabs, fetchTerminals]) + ) + // Why: pick up Settings → Terminal text size on return; panes stay mounted and update in place. useFocusEffect( useCallback(() => { @@ -3631,7 +3641,7 @@ export default function SessionScreen() { nativeChatInputLeaseReady, getActiveWorktreeConnectionId, beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, - nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome, + nativeChatBaseSend: nativeChatController.handleNativeChatSend, showToast, onSuccess: triggerSelection, onError: triggerError @@ -3962,8 +3972,8 @@ export default function SessionScreen() { pendingBrowserFocusPageIdRef.current = created.browserPageId } void fetchSessionTabs() - scheduleDelayedAction(() => void fetchPendingBrowserSessionTabs(), 400) - scheduleDelayedAction(() => void fetchPendingBrowserSessionTabs(), 1200) + scheduleDelayedAction(() => void fetchSessionTabs(), 400) + scheduleDelayedAction(() => void fetchSessionTabs(), 1200) return true } catch (err) { const message = err instanceof Error ? err.message : 'Failed to create browser' @@ -4079,9 +4089,6 @@ export default function SessionScreen() { reason: 'user' }) if (response.ok) { - if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) { - pendingBrowserFocusPageIdRef.current = null - } if (tab.type === 'terminal' && typeof tab.terminal === 'string') { const terminalHandle = tab.terminal unsubscribeTerminal(terminalHandle) @@ -4089,7 +4096,23 @@ export default function SessionScreen() { initializedHandlesRef.current.delete(terminalHandle) clearTerminalLiveInputDefault(terminalHandle) } - setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) + if (tab.type === 'file') { + fileDocLifecycleRef.current.close(tab.id, setFileDocs) + } + if (tab.type === 'markdown') { + markdownDocLifecycleRef.current.close(tab.id, (update) => { + setMarkdownDocs((current) => { + const next = update(current) + markdownDocsRef.current = next + return next + }) + }) + markdownSaveSeqRef.current.delete(tab.id) + markdownSaveInFlightRef.current.delete(tab.id) + } + const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id) + sessionTabsRef.current = remainingTabs + setSessionTabs(remainingTabs) // Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab. closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000) if (activeSessionTabId === tab.id) { @@ -5091,8 +5114,8 @@ export default function SessionScreen() { { label: 'Browser', icon: Globe, - closeBeforePress: true, onPress: () => { + setShowCreateTabDrawer(false) if (browserScreencastSupported !== true) { showToast('Desktop update required for mobile browser streaming', 1600) return @@ -5168,11 +5191,9 @@ export default function SessionScreen() { { label: 'Refresh', icon: RefreshCw, - // Why: dirty refresh opens ConfirmModal; wait for this sheet's native - // Modal to unmount first (same dual-Modal race as tab Rename, #10331). - closeBeforePress: true, onPress: () => { const target = markdownActionTarget + setMarkdownActionTarget(null) if (target) { discardMarkdownLocalContent(target) } diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index a9d24ae637a..b25a40682ca 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -137,9 +137,15 @@ import { clearMobileTaskCopyFeedbackTimer, scheduleMobileTaskCopyFeedbackReset } from '../../../src/tasks/mobile-task-copy-feedback-timer' +import { + createMobileItemPrFileContentScope, + createMobileProjectPrFileContentScope, + useMobilePrFileContentCache +} from '../../../src/tasks/use-mobile-pr-file-content-cache' import type { BaseRefSearchResult, GitHubOwnerRepo, + GitHubPRFileContents, PersistedTrustedOrcaHooks, SparsePreset, TuiAgent @@ -358,13 +364,6 @@ type GitHubDetailCheck = { url?: string | null } -type GitHubPRFileContents = { - original: string - modified: string - originalIsBinary: boolean - modifiedIsBinary: boolean -} - type DetailPayload = | { provider: 'github' @@ -2263,8 +2262,6 @@ export default function MobileTasksScreen() { const [itemReviewersDraft, setItemReviewersDraft] = useState('') const [itemReplyDrafts, setItemReplyDrafts] = useState>({}) const [expandedPrFilePath, setExpandedPrFilePath] = useState(null) - const [prFileContents, setPrFileContents] = useState>({}) - const [prFileLoadingPath, setPrFileLoadingPath] = useState(null) const [prFileCommentDrafts, setPrFileCommentDrafts] = useState>({}) const [copiedLinkKey, setCopiedLinkKey] = useState(null) const copiedLinkResetTimerRef = useRef | null>(null) @@ -2602,6 +2599,23 @@ export default function MobileTasksScreen() { () => (projectRowItem ? findProjectRowRepo(projectRowItem) : null), [findProjectRowRepo, projectRowItem] ) + const itemPrFileContentScope = createMobileItemPrFileContentScope(actionItem, detailPayload) + const projectPrFileContentScope = createMobileProjectPrFileContentScope( + projectRowItem, + projectRowHostedRepo, + projectRowDetail, + projectRowItem ? projectRowGitHubRepository(projectRowItem, activeGitHubProjectHost) : null + ) + const activePrFileContentScope = projectRowItem + ? projectPrFileContentScope + : itemPrFileContentScope + const { + clear: clearPrFileContents, + contents: prFileContents, + load: loadPrFileContent, + loadingPath: prFileLoadingPath + } = useMobilePrFileContentCache(activePrFileContentScope) + const itemReviewerCandidates = useMemo(() => { if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') { return [] @@ -4141,8 +4155,7 @@ export default function MobileTasksScreen() { setItemReviewersDraft('') setItemReplyDrafts({}) setExpandedPrFilePath(null) - setPrFileContents({}) - setPrFileLoadingPath(null) + clearPrFileContents() setPrFileCommentDrafts({}) setExpandedResolvedCommentGroups(new Set()) return @@ -4157,11 +4170,10 @@ export default function MobileTasksScreen() { setItemReviewersDraft('') setItemReplyDrafts({}) setExpandedPrFilePath(null) - setPrFileContents({}) - setPrFileLoadingPath(null) + clearPrFileContents() setPrFileCommentDrafts({}) setExpandedResolvedCommentGroups(new Set()) - }, [actionItem]) + }, [actionItem, clearPrFileContents]) useEffect(() => { if (!detailPayload) { @@ -4458,8 +4470,7 @@ export default function MobileTasksScreen() { setProjectEditingCommentDraft('') setProjectReviewersDraft('') setExpandedPrFilePath(null) - setPrFileContents({}) - setPrFileLoadingPath(null) + clearPrFileContents() setPrFileCommentDrafts({}) setProjectFieldDrafts({}) return @@ -4474,8 +4485,7 @@ export default function MobileTasksScreen() { setProjectEditingCommentDraft('') setProjectReviewersDraft('') setExpandedPrFilePath(null) - setPrFileContents({}) - setPrFileLoadingPath(null) + clearPrFileContents() setPrFileCommentDrafts({}) setProjectFieldDrafts( Object.fromEntries( @@ -4579,6 +4589,7 @@ export default function MobileTasksScreen() { } }, [ activeGitHubProjectHost, + clearPrFileContents, client, githubProjectTable, projectRowDetailRefreshSeq, @@ -6549,9 +6560,6 @@ export default function MobileTasksScreen() { return } setExpandedPrFilePath(file.path) - if (prFileContents[file.path]) { - return - } const repo = findProjectRowRepo(row) if ( !client || @@ -6560,49 +6568,45 @@ export default function MobileTasksScreen() { !row.content.number || projectRowDetail?.provider !== 'github' || !projectRowDetail.headSha || - !projectRowDetail.baseSha + !projectRowDetail.baseSha || + !projectPrFileContentScope ) { setProjectRowDetailError('Unable to load file contents for this pull request.') return } - setPrFileLoadingPath(file.path) - setProjectRowDetailError('') - try { - const response = await client.sendRequest( - 'github.prFileContents', - { - repo: `id:${repo.id}`, - prNumber: row.content.number, - prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), - path: file.path, - oldPath: file.oldPath, - status: file.status ?? 'modified', - headSha: projectRowDetail.headSha, - baseSha: projectRowDetail.baseSha - }, - { timeoutMs: 30_000 } - ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - setPrFileContents((current) => ({ - ...current, - [file.path]: response.result as GitHubPRFileContents - })) - } catch (err) { - setProjectRowDetailError( - err instanceof Error ? err.message : 'Failed to load file contents' - ) - } finally { - setPrFileLoadingPath(null) - } + await loadPrFileContent( + projectPrFileContentScope, + file, + async () => { + const response = await client.sendRequest( + 'github.prFileContents', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + path: file.path, + oldPath: file.oldPath, + status: file.status ?? 'modified', + headSha: projectRowDetail.headSha, + baseSha: projectRowDetail.baseSha + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + return response.result + }, + setProjectRowDetailError + ) }, [ activeGitHubProjectHost, client, expandedPrFilePath, findProjectRowRepo, - prFileContents, + loadPrFileContent, + projectPrFileContentScope, projectRowDetail ] ) @@ -7526,49 +7530,43 @@ export default function MobileTasksScreen() { return } setExpandedPrFilePath(file.path) - if (prFileContents[file.path]) { - return - } if ( !client || item.source.type !== 'pr' || detailPayload?.provider !== 'github' || !detailPayload.headSha || - !detailPayload.baseSha + !detailPayload.baseSha || + !itemPrFileContentScope ) { setError('Unable to load file contents for this pull request.') return } - setPrFileLoadingPath(file.path) - setError('') - try { - const response = await client.sendRequest( - 'github.prFileContents', - { - repo: `id:${item.source.repoId}`, - prNumber: item.source.number, - path: file.path, - oldPath: file.oldPath, - status: file.status ?? 'modified', - headSha: detailPayload.headSha, - baseSha: detailPayload.baseSha - }, - { timeoutMs: 30_000 } - ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - setPrFileContents((current) => ({ - ...current, - [file.path]: response.result as GitHubPRFileContents - })) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load file contents') - } finally { - setPrFileLoadingPath(null) - } + await loadPrFileContent( + itemPrFileContentScope, + file, + async () => { + const response = await client.sendRequest( + 'github.prFileContents', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + path: file.path, + oldPath: file.oldPath, + status: file.status ?? 'modified', + headSha: detailPayload.headSha, + baseSha: detailPayload.baseSha + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + return response.result + }, + setError + ) }, - [client, detailPayload, expandedPrFilePath, prFileContents] + [client, detailPayload, expandedPrFilePath, itemPrFileContentScope, loadPrFileContent] ) const addGitHubFileReviewComment = useCallback( @@ -12322,7 +12320,7 @@ export default function MobileTasksScreen() { ) : prFileContents[file.path] ? ( @@ -13268,7 +13266,7 @@ export default function MobileTasksScreen() { ) : prFileContents[file.path] ? ( diff --git a/mobile/scripts/bounded-process-line-reader.mjs b/mobile/scripts/bounded-process-line-reader.mjs new file mode 100644 index 00000000000..a209f03eb0a --- /dev/null +++ b/mobile/scripts/bounded-process-line-reader.mjs @@ -0,0 +1,103 @@ +import { Buffer } from 'node:buffer' + +export const PROCESS_LINE_MAX_BYTES = 64 * 1024 +export const PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS = 64 * 1024 +const TRUNCATED_LINE_SUFFIX = '… [line truncated]' + +export function appendProcessOutputTail( + current, + line, + maxCodeUnits = PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS +) { + if (!Number.isSafeInteger(maxCodeUnits) || maxCodeUnits < 0) { + throw new RangeError('Process output tail limit must be a non-negative safe integer') + } + if (maxCodeUnits === 0) { + return '' + } + const appended = `${current}${line}\n` + return appended.length <= maxCodeUnits ? appended : appended.slice(-maxCodeUnits) +} + +export function attachBoundedProcessLineReader( + stream, + onLine, + maxLineBytes = PROCESS_LINE_MAX_BYTES +) { + if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 0) { + throw new RangeError('Process line limit must be a non-negative safe integer') + } + + const retained = Buffer.allocUnsafe(maxLineBytes) + let retainedBytes = 0 + let truncated = false + let swallowLineFeed = false + let closed = false + + const append = (bytes, start, end) => { + if (start >= end) { + return + } + const available = maxLineBytes - retainedBytes + const copied = Math.min(available, end - start) + if (copied > 0) { + bytes.copy(retained, retainedBytes, start, start + copied) + retainedBytes += copied + } + truncated ||= copied < end - start + } + + const emit = () => { + const line = retained.subarray(0, retainedBytes).toString('utf8') + onLine(truncated ? `${line}${TRUNCATED_LINE_SUFFIX}` : line) + retainedBytes = 0 + truncated = false + } + + const onData = (chunk) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + let start = 0 + if (swallowLineFeed) { + swallowLineFeed = false + if (bytes[0] === 0x0a) { + start = 1 + } + } + + for (let index = start; index < bytes.length; index += 1) { + const value = bytes[index] + if (value !== 0x0a && value !== 0x0d) { + continue + } + append(bytes, start, index) + emit() + if (value === 0x0d && index + 1 < bytes.length && bytes[index + 1] === 0x0a) { + index += 1 + } else if (value === 0x0d && index + 1 === bytes.length) { + swallowLineFeed = true + } + start = index + 1 + } + append(bytes, start, bytes.length) + } + + const detach = () => { + if (closed) { + return + } + closed = true + stream.off('data', onData) + stream.off('end', onEnd) + } + + const onEnd = () => { + if (retainedBytes > 0 || truncated) { + emit() + } + detach() + } + + stream.on('data', onData) + stream.on('end', onEnd) + return detach +} diff --git a/mobile/scripts/bounded-response-body.mjs b/mobile/scripts/bounded-response-body.mjs new file mode 100644 index 00000000000..cef6099d2bb --- /dev/null +++ b/mobile/scripts/bounded-response-body.mjs @@ -0,0 +1,36 @@ +export async function responseBodyIncludesWithinLimit(response, needle, maxBytes) { + const declaredLength = response.headers.get('content-length') + if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) { + await response.body?.cancel().catch(() => undefined) + return false + } + if (!response.body) { + return false + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let observedBytes = 0 + let suffix = '' + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + return `${suffix}${decoder.decode()}`.includes(needle) + } + observedBytes += value.byteLength + if (observedBytes > maxBytes) { + await reader.cancel().catch(() => undefined) + return false + } + const candidate = suffix + decoder.decode(value, { stream: true }) + if (candidate.includes(needle)) { + await reader.cancel().catch(() => undefined) + return true + } + suffix = candidate.slice(-Math.max(0, needle.length - 1)) + } + } finally { + reader.releaseLock() + } +} diff --git a/mobile/scripts/start-emulator-pairing-runtime.mjs b/mobile/scripts/start-emulator-pairing-runtime.mjs index 02078825db4..3e23c54093b 100644 --- a/mobile/scripts/start-emulator-pairing-runtime.mjs +++ b/mobile/scripts/start-emulator-pairing-runtime.mjs @@ -3,7 +3,10 @@ import { mkdirSync, mkdtempSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import process from 'node:process' -import readline from 'node:readline' +import { + appendProcessOutputTail, + attachBoundedProcessLineReader +} from './bounded-process-line-reader.mjs' function primaryLanIp(lanIpCandidates) { return lanIpCandidates()[0] || '127.0.0.1' @@ -66,15 +69,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce let stderr = '' let resolved = false let exited = false - let rl = null - let rlErr = null + let closeStdout = () => {} + let closeStderr = () => {} const stop = () => { if (!exited) { child.kill('SIGTERM') } - rl?.close() - rlErr?.close() + closeStdout() + closeStderr() child.stdout?.destroy() child.stderr?.destroy() } @@ -119,15 +122,17 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce reject(error) } - rl = readline.createInterface({ input: child.stdout }) - rl.on('line', (line) => { - output += line + '\n' + closeStdout = attachBoundedProcessLineReader(child.stdout, (line) => { + if (!resolved) { + output = appendProcessOutputTail(output, line) + } handleRuntimeLine(line, finishResolve) }) - rlErr = readline.createInterface({ input: child.stderr }) - rlErr.on('line', (line) => { - stderr += line + '\n' + closeStderr = attachBoundedProcessLineReader(child.stderr, (line) => { + if (!resolved) { + stderr = appendProcessOutputTail(stderr, line) + } }) child.on('error', (error) => { diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs index d6489b530ee..8c5e11f944c 100755 --- a/mobile/scripts/start-emulator.mjs +++ b/mobile/scripts/start-emulator.mjs @@ -22,16 +22,21 @@ import os from 'node:os' import { promisify } from 'node:util' import path from 'node:path' import process from 'node:process' -import readline from 'node:readline' import { registerWorktreeForPairingRuntime, startHeadlessPairingRuntime } from './start-emulator-pairing-runtime.mjs' +import { + appendProcessOutputTail, + attachBoundedProcessLineReader +} from './bounded-process-line-reader.mjs' +import { responseBodyIncludesWithinLimit } from './bounded-response-body.mjs' import { ensureMobileExpoCli, getMobileExpoExecutablePath } from './mobile-expo-cli.mjs' const execFileAsync = promisify(execFile) const DEFAULT_METRO_PORT = 8081 const METRO_PORT_SEARCH_LIMIT = 100 +const METRO_STATUS_MAX_BYTES = 64 * 1024 // Parse CLI arguments const args = process.argv.slice(2) @@ -113,6 +118,22 @@ function logInfo(message) { log(`[info] ${message}`, 'yellow') } +function createBackpressuredLineWriter(source, target, color) { + let waitingForDrain = false + return (line) => { + const accepted = target.write(color + line + colors.reset + '\n') + if (accepted || waitingForDrain) { + return + } + waitingForDrain = true + source.pause() + target.once('drain', () => { + waitingForDrain = false + source.resume() + }) + } +} + function assertIosSimulatorPlatform() { if (process.platform !== 'darwin') { throw new Error('iOS Simulator automation requires macOS and Xcode.') @@ -382,8 +403,10 @@ async function startMetro(worktree) { let url = null let resolved = false let exited = false - let rl = null - let rlErr = null + let closeStdout = () => {} + let closeStderr = () => {} + const writeStdoutLine = createBackpressuredLineWriter(metro.stdout, process.stdout, colors.dim) + const writeStderrLine = createBackpressuredLineWriter(metro.stderr, process.stderr, colors.red) const metroResult = () => ({ process: metro, @@ -391,8 +414,8 @@ async function startMetro(worktree) { output, isExited: () => exited, closeOutput: () => { - rl?.close() - rlErr?.close() + closeStdout() + closeStderr() metro.stdin?.destroy() metro.stdout?.destroy() metro.stderr?.destroy() @@ -400,10 +423,11 @@ async function startMetro(worktree) { }) // Parse Metro output for the development URL - rl = readline.createInterface({ input: metro.stdout }) - rl.on('line', (line) => { - output += line + '\n' - process.stdout.write(colors.dim + line + colors.reset + '\n') + closeStdout = attachBoundedProcessLineReader(metro.stdout, (line) => { + if (!resolved) { + output = appendProcessOutputTail(output, line) + } + writeStdoutLine(line) // Look for "Waiting on" message from Metro // When Metro says "Waiting on http://localhost:8081", we need to construct the dev-client URL @@ -446,10 +470,11 @@ async function startMetro(worktree) { }) // Also check stderr - rlErr = readline.createInterface({ input: metro.stderr }) - rlErr.on('line', (line) => { - output += line + '\n' - process.stderr.write(colors.red + line + colors.reset + '\n') + closeStderr = attachBoundedProcessLineReader(metro.stderr, (line) => { + if (!resolved) { + output = appendProcessOutputTail(output, line) + } + writeStderrLine(line) }) metro.on('error', (error) => { @@ -547,7 +572,11 @@ async function verifyMetro(url) { try { const response = await fetch(statusUrl, { signal: controller.signal }) - return (await response.text()).includes('packager-status:running') + return await responseBodyIncludesWithinLimit( + response, + 'packager-status:running', + METRO_STATUS_MAX_BYTES + ) } catch { return false } finally { diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index 683b972bfa8..bc516a4787a 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -1,7 +1,4 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */ -// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro -// can't resolve Node's builtin in a React Native bundle. -import { Buffer } from 'buffer' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, @@ -42,6 +39,10 @@ import { getInitialMobileBrowserViewMode, saveMobileBrowserViewMode } from './mobile-browser-view-mode-state' +import { + createMobileBrowserFrameDataUri, + MobileBrowserFrameCache +} from './mobile-browser-frame-cache' import { clampBrowserZoomState, computeBrowserFrameGeometry, @@ -108,14 +109,7 @@ const TOUCH_CLICK_RADIUS_DIP = 14 const MIN_ZOOM = 1 const MAX_ZOOM = 3.5 const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 } -const BROWSER_FRAME_CACHE_LIMIT = 4 - -type BrowserFrameCacheEntry = { - uri: string - metadata: BrowserScreencastFrameMetadata -} - -const browserFrameCache = new Map() +const browserFrameCache = new MobileBrowserFrameCache() type BrowserPageParams = { worktree: string @@ -143,7 +137,7 @@ export function MobileBrowserPane({ getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId) ) const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId, browserViewMode) - const cachedInitialFrame = peekCachedBrowserFrame(cacheKey) + const cachedInitialFrame = browserFrameCache.peek(cacheKey) const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url)) const [addressFocused, setAddressFocused] = useState(false) const [addressSyncState, setAddressSyncState] = useState({ @@ -229,7 +223,7 @@ export function MobileBrowserPane({ const subscription = AppState.addEventListener('change', (nextState) => { const active = nextState === 'active' if (!active) { - clearCachedBrowserFramesForWorktree(worktreeId) + browserFrameCache.clearWorktree(worktreeId) } setAppActive(active) }) @@ -280,8 +274,14 @@ export function MobileBrowserPane({ frameMetadataRef.current = frame.metadata setFrameMetadata(frame.metadata) } - const nextFrameUri = createBrowserFrameDataUri(frame) - cacheBrowserFrame(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata }) + const nextFrameUri = createMobileBrowserFrameDataUri(frame) + if (!nextFrameUri) { + busyRef.current = false + setBusy(false) + setError('Browser frame is too large to display safely.') + return + } + browserFrameCache.set(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata }) if (!frameMountedRef.current) { frameUriRef.current = nextFrameUri frameMountedRef.current = true @@ -389,7 +389,7 @@ export function MobileBrowserPane({ const sameStream = Boolean(cacheKey) && lastStreamCacheKeyRef.current === cacheKey lastStreamCacheKeyRef.current = cacheKey if (!sameStream || !frameUriRef.current) { - const cachedFrame = getCachedBrowserFrame(cacheKey) + const cachedFrame = browserFrameCache.get(cacheKey) if (cachedFrame) { frameUriRef.current = cachedFrame.uri frameMountedRef.current = true @@ -1303,10 +1303,6 @@ function buttonColor(enabled: boolean): string { return enabled ? colors.textSecondary : colors.textMuted } -function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string { - return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}` -} - function makeBrowserFrameCacheKey( worktreeId: string, browserPageId: string | null, @@ -1315,47 +1311,6 @@ function makeBrowserFrameCacheKey( return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null } -function clearCachedBrowserFramesForWorktree(worktreeId: string): void { - const prefix = `${worktreeId}:` - for (const key of browserFrameCache.keys()) { - if (key.startsWith(prefix)) { - browserFrameCache.delete(key) - } - } -} - -function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { - if (!cacheKey) { - return null - } - const cached = browserFrameCache.get(cacheKey) - if (!cached) { - return null - } - browserFrameCache.delete(cacheKey) - browserFrameCache.set(cacheKey, cached) - return cached -} - -function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { - return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null -} - -function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void { - if (!cacheKey) { - return - } - browserFrameCache.delete(cacheKey) - browserFrameCache.set(cacheKey, entry) - while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) { - const oldestKey = browserFrameCache.keys().next().value - if (typeof oldestKey !== 'string') { - break - } - browserFrameCache.delete(oldestKey) - } -} - function updateBrowserLayerVisibility( layers: [View | null, View | null], visible: FrameLayer diff --git a/mobile/src/browser/mobile-browser-frame-cache.test.ts b/mobile/src/browser/mobile-browser-frame-cache.test.ts new file mode 100644 index 00000000000..79280dd1055 --- /dev/null +++ b/mobile/src/browser/mobile-browser-frame-cache.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + BrowserScreencastOpcode, + type BrowserScreencastFrame +} from '../transport/browser-screencast-protocol' +import { + createMobileBrowserFrameDataUri, + MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES, + MobileBrowserFrameCache +} from './mobile-browser-frame-cache' + +function entry(uri: string) { + return { uri, metadata: {} } +} + +function frame(imageBytes: number): BrowserScreencastFrame { + return { + opcode: BrowserScreencastOpcode.Frame, + seq: 1, + format: 'jpeg', + metadata: {}, + image: new Uint8Array(imageBytes) + } +} + +describe('MobileBrowserFrameCache', () => { + it('preserves LRU behavior below the count and retained-character limits', () => { + const cache = new MobileBrowserFrameCache(2, 100) + cache.set('first', entry('frame-1')) + cache.set('second', entry('frame-2')) + + expect(cache.get('first')).toEqual(entry('frame-1')) + cache.set('third', entry('frame-3')) + + expect(cache.peek('second')).toBeNull() + expect(cache.evidence().keysOldestFirst).toEqual(['first', 'third']) + }) + + it('accepts the exact aggregate budget and evicts oldest at one character over', () => { + const cache = new MobileBrowserFrameCache(4, 20) + expect(cache.set('a', entry('x'.repeat(9)))).toBe(true) + expect(cache.set('b', entry('x'.repeat(9)))).toBe(true) + expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 20 }) + + expect(cache.set('c', entry(''))).toBe(true) + expect(cache.peek('a')).toBeNull() + expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 11 }) + }) + + it('does not retain one entry larger than the full budget', () => { + const cache = new MobileBrowserFrameCache(4, 10) + + expect(cache.set('key', entry('x'.repeat(8)))).toBe(false) + expect(cache.evidence()).toEqual({ + entryCount: 0, + retainedCharacters: 0, + keysOldestFirst: [] + }) + }) +}) + +describe('createMobileBrowserFrameDataUri', () => { + it('accepts the exact image-byte limit and rejects one byte over before base64 expansion', () => { + expect(MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES).toBe(8 * 1024 * 1024) + expect(createMobileBrowserFrameDataUri(frame(4), 4)).toBe('data:image/jpeg;base64,AAAAAA==') + expect(createMobileBrowserFrameDataUri(frame(5), 4)).toBeNull() + }) +}) diff --git a/mobile/src/browser/mobile-browser-frame-cache.ts b/mobile/src/browser/mobile-browser-frame-cache.ts new file mode 100644 index 00000000000..0d856c546b3 --- /dev/null +++ b/mobile/src/browser/mobile-browser-frame-cache.ts @@ -0,0 +1,134 @@ +import { Buffer } from 'buffer' +import type { + BrowserScreencastFrame, + BrowserScreencastFrameMetadata +} from '../transport/browser-screencast-protocol' + +export const MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES = 8 * 1024 * 1024 +export const MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES = 4 +export const MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS = 16 * 1024 * 1024 + +export type MobileBrowserFrameCacheEntry = { + uri: string + metadata: BrowserScreencastFrameMetadata +} + +export type MobileBrowserFrameCacheEvidence = { + entryCount: number + retainedCharacters: number + keysOldestFirst: string[] +} + +type RetainedFrame = { + entry: MobileBrowserFrameCacheEntry + retainedCharacters: number +} + +export class MobileBrowserFrameCache { + private readonly entries = new Map() + private retainedCharacters = 0 + + constructor( + private readonly maxEntries = MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES, + private readonly maxRetainedCharacters = MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS + ) { + if ( + !Number.isInteger(maxEntries) || + maxEntries < 1 || + !Number.isSafeInteger(maxRetainedCharacters) || + maxRetainedCharacters < 1 + ) { + throw new Error('Mobile browser frame cache limits must be positive integers') + } + } + + get(key: string | null): MobileBrowserFrameCacheEntry | null { + if (!key) { + return null + } + const retained = this.entries.get(key) + if (!retained) { + return null + } + this.entries.delete(key) + this.entries.set(key, retained) + return retained.entry + } + + peek(key: string | null): MobileBrowserFrameCacheEntry | null { + return key ? (this.entries.get(key)?.entry ?? null) : null + } + + set(key: string | null, entry: MobileBrowserFrameCacheEntry): boolean { + if (!key) { + return false + } + const retainedCharacters = key.length + entry.uri.length + const previous = this.entries.get(key) + if (previous) { + this.retainedCharacters -= previous.retainedCharacters + this.entries.delete(key) + } + if (retainedCharacters > this.maxRetainedCharacters) { + return false + } + this.entries.set(key, { entry, retainedCharacters }) + this.retainedCharacters += retainedCharacters + this.evictOverflow() + return this.entries.has(key) + } + + clearWorktree(worktreeId: string): void { + const prefix = `${worktreeId}:` + for (const key of this.entries.keys()) { + if (key.startsWith(prefix)) { + this.delete(key) + } + } + } + + clear(): void { + this.entries.clear() + this.retainedCharacters = 0 + } + + evidence(): MobileBrowserFrameCacheEvidence { + return { + entryCount: this.entries.size, + retainedCharacters: this.retainedCharacters, + keysOldestFirst: [...this.entries.keys()] + } + } + + private delete(key: string): void { + const retained = this.entries.get(key) + if (!retained) { + return + } + this.retainedCharacters -= retained.retainedCharacters + this.entries.delete(key) + } + + private evictOverflow(): void { + while ( + this.entries.size > this.maxEntries || + this.retainedCharacters > this.maxRetainedCharacters + ) { + const oldestKey = this.entries.keys().next().value + if (typeof oldestKey !== 'string') { + return + } + this.delete(oldestKey) + } + } +} + +export function createMobileBrowserFrameDataUri( + frame: BrowserScreencastFrame, + maxImageBytes = MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES +): string | null { + if (frame.image.byteLength > maxImageBytes) { + return null + } + return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}` +} diff --git a/mobile/src/cache/mobile-rpc-list-cache.test.ts b/mobile/src/cache/mobile-rpc-list-cache.test.ts new file mode 100644 index 00000000000..50eeccf027e --- /dev/null +++ b/mobile/src/cache/mobile-rpc-list-cache.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { MobileRpcListCache } from './mobile-rpc-list-cache' + +describe('MobileRpcListCache', () => { + it('preserves values below the limits and expires them at the existing age boundary', () => { + const cache = new MobileRpcListCache(100, 2, 3, 100) + const values = [{ id: 'repo' }] + + expect(cache.set('host', values, 1_000)).toBe(true) + expect(cache.get('host', 1_100)).toBe(values) + expect(cache.get('host', 1_101)).toBeNull() + }) + + it('accepts the exact item cap and rejects one over', () => { + const cache = new MobileRpcListCache(100, 2, 3, 1_000) + const exact = [1, 2, 3] + + expect(cache.set('host', exact)).toBe(true) + expect(cache.get('host')).toBe(exact) + expect(cache.set('host', [...exact, 4])).toBe(false) + expect(cache.get('host')).toBeNull() + }) + + it('accepts the exact aggregate byte budget and evicts oldest at one over', () => { + const probe = new MobileRpcListCache(100, 3, 3, 1_000) + probe.set('first', ['a']) + const firstBytes = probe.evidence().retainedBytes + probe.clear() + + const cache = new MobileRpcListCache(100, 3, 3, firstBytes * 2) + expect(cache.set('first', ['a'])).toBe(true) + expect(cache.set('other', ['a'])).toBe(true) + expect(cache.evidence().retainedBytes).toBe(firstBytes * 2) + + expect(cache.set('x', [])).toBe(true) + expect(cache.get('first')).toBeNull() + expect(cache.evidence().retainedBytes).toBeLessThanOrEqual(firstBytes * 2) + }) + + it('rejects a single payload larger than the full byte budget', () => { + const cache = new MobileRpcListCache(100, 2, 3, 20) + + expect(cache.set('host', ['a'.repeat(100)])).toBe(false) + expect(cache.evidence()).toEqual({ + entryCount: 0, + retainedBytes: 0, + keysOldestFirst: [] + }) + }) +}) diff --git a/mobile/src/cache/mobile-rpc-list-cache.ts b/mobile/src/cache/mobile-rpc-list-cache.ts new file mode 100644 index 00000000000..33619a0fed6 --- /dev/null +++ b/mobile/src/cache/mobile-rpc-list-cache.ts @@ -0,0 +1,126 @@ +import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json' + +export type MobileRpcListCacheEvidence = { + entryCount: number + retainedBytes: number + keysOldestFirst: string[] +} + +type CacheEntry = { + values: unknown[] + at: number + retainedBytes: number +} + +export class MobileRpcListCache { + private readonly entries = new Map() + private retainedBytes = 0 + + constructor( + private readonly maxAgeMs: number, + private readonly maxEntries: number, + private readonly maxItemsPerEntry: number, + private readonly maxRetainedBytes: number + ) { + if ( + !Number.isFinite(maxAgeMs) || + maxAgeMs < 0 || + !Number.isInteger(maxEntries) || + maxEntries < 1 || + !Number.isInteger(maxItemsPerEntry) || + maxItemsPerEntry < 1 || + !Number.isSafeInteger(maxRetainedBytes) || + maxRetainedBytes < 1 + ) { + throw new Error('Mobile RPC list cache limits must be positive') + } + } + + set(key: string, values: unknown[], now = Date.now()): boolean { + this.delete(key) + if (values.length > this.maxItemsPerEntry) { + return false + } + let serialized: string + try { + serialized = stringifyMobileOutboundJson({ key, values }, this.maxRetainedBytes) + } catch { + return false + } + const retainedBytes = utf8ByteLength(serialized) + if (retainedBytes > this.maxRetainedBytes) { + return false + } + this.entries.set(key, { values, at: now, retainedBytes }) + this.retainedBytes += retainedBytes + this.evictOverflow() + return this.entries.has(key) + } + + get(key: string, now = Date.now()): unknown[] | null { + const entry = this.entries.get(key) + if (!entry) { + return null + } + if (now - entry.at > this.maxAgeMs) { + this.delete(key) + return null + } + return entry.values + } + + clear(): void { + this.entries.clear() + this.retainedBytes = 0 + } + + evidence(): MobileRpcListCacheEvidence { + return { + entryCount: this.entries.size, + retainedBytes: this.retainedBytes, + keysOldestFirst: [...this.entries.keys()] + } + } + + private delete(key: string): void { + const entry = this.entries.get(key) + if (!entry) { + return + } + this.retainedBytes -= entry.retainedBytes + this.entries.delete(key) + } + + private evictOverflow(): void { + while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxRetainedBytes) { + const oldestKey = this.entries.keys().next().value + if (typeof oldestKey !== 'string') { + return + } + this.delete(oldestKey) + } + } +} + +function utf8ByteLength(value: string): number { + let bytes = 0 + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code <= 0x7f) { + bytes += 1 + } else if (code <= 0x7ff) { + bytes += 2 + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4 + index += 1 + } else { + bytes += 3 + } + } else { + bytes += 3 + } + } + return bytes +} diff --git a/mobile/src/cache/repo-cache.test.ts b/mobile/src/cache/repo-cache.test.ts index 156fa507619..adef8d02fcc 100644 --- a/mobile/src/cache/repo-cache.test.ts +++ b/mobile/src/cache/repo-cache.test.ts @@ -1,8 +1,17 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getCachedRepos, setCachedRepos } from './repo-cache' +import { + getCachedRepos, + MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST, + resetRepoCacheForTests, + setCachedRepos +} from './repo-cache' describe('repo cache', () => { + beforeEach(() => { + resetRepoCacheForTests() + }) + it('returns recent host-scoped repos', () => { const repos = [{ id: 'repo-1' }] @@ -23,4 +32,13 @@ describe('repo cache', () => { vi.useRealTimers() } }) + + it('retains the exact per-host item cap and rejects one over', () => { + const exact = Array.from({ length: MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST }, () => null) + setCachedRepos('host', exact) + expect(getCachedRepos('host')).toBe(exact) + + setCachedRepos('host', [...exact, null]) + expect(getCachedRepos('host')).toBeNull() + }) }) diff --git a/mobile/src/cache/repo-cache.ts b/mobile/src/cache/repo-cache.ts index ecb485c4d67..e5e407aee06 100644 --- a/mobile/src/cache/repo-cache.ts +++ b/mobile/src/cache/repo-cache.ts @@ -2,35 +2,29 @@ // host-scoped cache lets workspace creation open from the last known list while // a fresh repo.list refresh happens in the background. -type CachedRepos = { - repos: unknown[] - at: number -} - -const cache = new Map() +import { MobileRpcListCache } from './mobile-rpc-list-cache' const MAX_AGE_MS = 60_000 -const MAX_ENTRIES = 20 +export const MOBILE_REPO_CACHE_MAX_ENTRIES = 20 +export const MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST = 10_000 +export const MOBILE_REPO_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024 + +const cache = new MobileRpcListCache( + MAX_AGE_MS, + MOBILE_REPO_CACHE_MAX_ENTRIES, + MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST, + MOBILE_REPO_CACHE_MAX_RETAINED_BYTES +) export function setCachedRepos(hostId: string, repos: unknown[]): void { - cache.delete(hostId) - cache.set(hostId, { repos, at: Date.now() }) - if (cache.size > MAX_ENTRIES) { - const oldest = cache.keys().next().value - if (oldest) { - cache.delete(oldest) - } - } + cache.set(hostId, repos) } export function getCachedRepos(hostId: string): unknown[] | null { - const entry = cache.get(hostId) - if (!entry) { - return null - } - if (Date.now() - entry.at > MAX_AGE_MS) { - cache.delete(hostId) - return null - } - return entry.repos + return cache.get(hostId) +} + +/** Test-only: clear process-lifetime cache state between cases. */ +export function resetRepoCacheForTests(): void { + cache.clear() } diff --git a/mobile/src/cache/worktree-cache.test.ts b/mobile/src/cache/worktree-cache.test.ts index f167c6c547d..5412627516c 100644 --- a/mobile/src/cache/worktree-cache.test.ts +++ b/mobile/src/cache/worktree-cache.test.ts @@ -1,10 +1,19 @@ -import { describe, expect, it } from 'vitest' -import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache' +import { beforeEach, describe, expect, it } from 'vitest' +import { + getCachedWorktrees, + MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST, + resetWorktreeCacheForTests, + setCachedWorktrees +} from './worktree-cache' // Why: AC #8498 guarantees a reconnect refetch writes through the // same cache path the host detail screen seeds from, so a reconnect can't // serve a stale snapshot. This unit pins the write-through contract. describe('worktree-cache write-through', () => { + beforeEach(() => { + resetWorktreeCacheForTests() + }) + it('returns the most-recently written snapshot, not a stale one', () => { const hostId = 'host-write-through' const stale = [{ worktreeId: 'a', name: 'stale' }] @@ -40,4 +49,13 @@ describe('worktree-cache write-through', () => { // A fresh screen mount reads the cache — must see the connected set. expect(getCachedWorktrees(hostId)).toEqual(reconnected) }) + + it('retains the exact per-host item cap and rejects one over', () => { + const exact = Array.from({ length: MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST }, () => null) + setCachedWorktrees('host', exact) + expect(getCachedWorktrees('host')).toBe(exact) + + setCachedWorktrees('host', [...exact, null]) + expect(getCachedWorktrees('host')).toBeNull() + }) }) diff --git a/mobile/src/cache/worktree-cache.ts b/mobile/src/cache/worktree-cache.ts index 0b05f78b260..b95d97f8549 100644 --- a/mobile/src/cache/worktree-cache.ts +++ b/mobile/src/cache/worktree-cache.ts @@ -2,38 +2,29 @@ // so the host detail page can render instantly on navigation instead of // waiting for a fresh RPC connection + fetch cycle. -type CachedWorktrees = { - worktrees: unknown[] - at: number -} - -const cache = new Map() +import { MobileRpcListCache } from './mobile-rpc-list-cache' const MAX_AGE_MS = 30_000 -const MAX_ENTRIES = 20 +export const MOBILE_WORKTREE_CACHE_MAX_ENTRIES = 20 +export const MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST = 10_000 +export const MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024 + +const cache = new MobileRpcListCache( + MAX_AGE_MS, + MOBILE_WORKTREE_CACHE_MAX_ENTRIES, + MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST, + MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES +) export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void { - // Why: Map.set on an existing key does not move it to the end of iteration - // order. Delete first so the re-inserted key becomes the newest entry, - // giving us true LRU eviction when the cap is hit. - cache.delete(hostId) - cache.set(hostId, { worktrees, at: Date.now() }) - if (cache.size > MAX_ENTRIES) { - const oldest = cache.keys().next().value - if (oldest) { - cache.delete(oldest) - } - } + cache.set(hostId, worktrees) } export function getCachedWorktrees(hostId: string): unknown[] | null { - const entry = cache.get(hostId) - if (!entry) { - return null - } - if (Date.now() - entry.at > MAX_AGE_MS) { - cache.delete(hostId) - return null - } - return entry.worktrees + return cache.get(hostId) +} + +/** Test-only: clear process-lifetime cache state between cases. */ +export function resetWorktreeCacheForTests(): void { + cache.clear() } diff --git a/mobile/src/components/pr-sidebar/markdown-blocks.test.ts b/mobile/src/components/pr-sidebar/markdown-blocks.test.ts index ab0026854b4..c140e39ddf8 100644 --- a/mobile/src/components/pr-sidebar/markdown-blocks.test.ts +++ b/mobile/src/components/pr-sidebar/markdown-blocks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseInline, parseMarkdownBlocks } from './markdown-blocks' +import { COMMENT_MARKDOWN_LIMITS, parseInline, parseMarkdownBlocks } from './markdown-blocks' describe('parseMarkdownBlocks', () => { it('classifies headings, fenced code, quotes, lists, hr, and paragraphs', () => { @@ -100,6 +100,42 @@ describe('parseMarkdownBlocks tables', () => { ]) }) + it('accepts the exact table column limit and falls back one column later', () => { + const header = Array.from({ length: COMMENT_MARKDOWN_LIMITS.tableColumns }, (_, index) => + String(index) + ).join('|') + const delimiter = Array.from( + { length: COMMENT_MARKDOWN_LIMITS.tableColumns }, + () => '---' + ).join('|') + + expect(parseMarkdownBlocks(`${header}\n${delimiter}`)[0]).toMatchObject({ + kind: 'table', + headers: { length: COMMENT_MARKDOWN_LIMITS.tableColumns } + }) + expect(parseMarkdownBlocks(`${header}|extra\n${delimiter}|---`)[0]).toMatchObject({ + kind: 'paragraph' + }) + }) + + it('falls back to a bounded preview before splitting oversized input', () => { + const exact = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits) + const content = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits + 1) + const [block] = parseMarkdownBlocks(content) + + expect(parseMarkdownBlocks(exact)).toEqual([{ kind: 'paragraph', text: exact }]) + expect(block).toMatchObject({ kind: 'paragraph' }) + expect(block?.kind === 'paragraph' ? block.text.length : 0).toBeLessThan( + COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits + 100 + ) + }) + + it('checks normalized break tags before allocating line arrays', () => { + const content = '
'.repeat(COMMENT_MARKDOWN_LIMITS.lines + 1) + + expect(parseMarkdownBlocks(content)[0]).toMatchObject({ kind: 'paragraph' }) + }) + it('reads per-column alignment from the delimiter row', () => { const md = ['| L | C | R |', '| :--- | :---: | ---: |', '| a | b | c |'].join('\n') const block = parseMarkdownBlocks(md)[0] diff --git a/mobile/src/components/pr-sidebar/markdown-blocks.ts b/mobile/src/components/pr-sidebar/markdown-blocks.ts index d5866585b68..dd597383914 100644 --- a/mobile/src/components/pr-sidebar/markdown-blocks.ts +++ b/mobile/src/components/pr-sidebar/markdown-blocks.ts @@ -40,6 +40,20 @@ const ORDERED = /^\s*\d+[.)]\s+(.*)$/ const HTML_BLOCK = /<(details|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/i const SUMMARY = /]*>([\s\S]*?)<\/summary>/i +export const COMMENT_MARKDOWN_LIMITS = { + sourceCodeUnits: 1024 * 1024, + lines: 10_000, + detailsDepth: 64, + tableColumns: 256, + tableRows: 2000, + tableCells: 50_000, + fallbackCodeUnits: 64 * 1024 +} as const + +const LIMIT_FALLBACK_SUFFIX = '\n\n[Comment preview truncated for safety]' + +class CommentMarkdownLimitError extends Error {} + // Removes residual HTML tags from rendered text so stray // etc. don't // show literally. Conservative: only matches `` / `` shapes, so a bare // "a < b" in prose is left alone. @@ -48,9 +62,56 @@ export function stripHtmlTags(text: string): string { } export function parseMarkdownBlocks(content: string): MarkdownBlock[] { + if (!isCommentMarkdownWithinPreparseLimits(content)) { + return commentMarkdownLimitFallback(content) + } // Drop HTML comments and normalize
before block parsing. const cleaned = content.replace(//g, '').replace(//gi, '\n') - return parseSegment(cleaned) + if (!isCommentMarkdownWithinPreparseLimits(cleaned)) { + return commentMarkdownLimitFallback(content) + } + try { + return parseSegment(cleaned) + } catch (error) { + if (error instanceof CommentMarkdownLimitError) { + return commentMarkdownLimitFallback(content) + } + throw error + } +} + +function isCommentMarkdownWithinPreparseLimits(content: string): boolean { + if (content.length > COMMENT_MARKDOWN_LIMITS.sourceCodeUnits) { + return false + } + let lines = 1 + let detailsDepth = 0 + const detailsPattern = /<\/?details\b/gi + for (let index = 0; index < content.length; index += 1) { + if (content.charCodeAt(index) !== 10) { + continue + } + lines += 1 + if (lines > COMMENT_MARKDOWN_LIMITS.lines) { + return false + } + } + for (const match of content.matchAll(detailsPattern)) { + if (match[0][1] === '/') { + detailsDepth = Math.max(0, detailsDepth - 1) + continue + } + detailsDepth += 1 + if (detailsDepth > COMMENT_MARKDOWN_LIMITS.detailsDepth) { + return false + } + } + return true +} + +function commentMarkdownLimitFallback(content: string): MarkdownBlock[] { + const preview = content.slice(0, COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits) + return [{ kind: 'paragraph', text: `${preview}${LIMIT_FALLBACK_SUFFIX}` }] } // Splits a segment at top-level
/
regions (preserving order), @@ -120,8 +181,17 @@ function parseLines(content: string): MarkdownBlock[] { const align = parseAlignRow(lines[i + 1]) i += 2 const rows: string[][] = [] + let tableCells = headers.length while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') { - rows.push(splitTableRow(lines[i])) + if (rows.length >= COMMENT_MARKDOWN_LIMITS.tableRows) { + throw new CommentMarkdownLimitError('Table row limit exceeded') + } + const row = splitTableRow(lines[i]) + tableCells += row.length + if (tableCells > COMMENT_MARKDOWN_LIMITS.tableCells) { + throw new CommentMarkdownLimitError('Table cell limit exceeded') + } + rows.push(row) i += 1 } blocks.push({ kind: 'table', headers, rows, align }) @@ -207,12 +277,18 @@ function splitTableRow(line: string): string[] { continue } if (ch === '|') { + if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) { + throw new CommentMarkdownLimitError('Table column limit exceeded') + } cells.push(cell.trim()) cell = '' continue } cell += ch } + if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) { + throw new CommentMarkdownLimitError('Table column limit exceeded') + } cells.push(cell.trim()) return cells } diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts index 0d003e9f456..86719d7c1cd 100644 --- a/mobile/src/files/MobileFileExplorerPanel.test.ts +++ b/mobile/src/files/MobileFileExplorerPanel.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileFileExplorerPanel } from './MobileFileExplorerPanel' +import { LEGACY_MOBILE_FILE_PATH_MAX_BYTES } from './file-list-fallback' import type { MobileDirEntry } from './file-tree' import type { RpcResponse } from '../transport/types' @@ -206,6 +207,18 @@ describe('MobileFileExplorerPanel', () => { expect(client.sendRequest).toHaveBeenCalledTimes(2) }) + it('rejects an extreme directory listing instead of silently truncating it', async () => { + const client = createMockClient({ + '': Array.from({ length: 10_001 }, (_, index) => entry(`file-${index}.txt`)) + }) + mockTransport.client = client + + const renderer = await renderExplorer() + + expect(renderedText(renderer)).toContain('This folder is too large to show safely on mobile') + expect(renderedText(renderer)).toContain('10,000 items') + }) + it('keeps the loaded tree visible during a transient disconnect', async () => { const client = createMockClient({ '': [entry('src', true), entry('README.md')] @@ -366,6 +379,43 @@ describe('MobileFileExplorerPanel', () => { expect(renderedText(renderer)).toContain('Showing first 5000') }) + it('reports a bounded-memory error when a legacy path exceeds the fallback cap', async () => { + const legacyClient: MockClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method === 'files.readDir') { + return { + id: 'response-id', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-id' } + } + } + return { + id: 'response-id', + ok: true, + result: { + files: [ + { + relativePath: 'x'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES + 1), + basename: 'oversized', + kind: 'text' + } + ], + totalCount: 1, + truncated: false + }, + _meta: { runtimeId: 'runtime-id' } + } + }) + } + mockTransport.client = legacyClient + + const renderer = await renderExplorer() + + expect(renderedText(renderer)).toContain('legacy file list is too large') + expect(renderedText(renderer)).toContain('Update Orca Desktop') + }) + it('reports the files.list failure when the fallback itself fails', async () => { const legacyClient: MockClient = { sendRequest: vi.fn(async (method: string): Promise => { diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index 7e67bf81b5f..d34c4ffd07a 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -16,14 +16,15 @@ import { flattenDirectoryCache, getDirectoryCacheState, type DirectoryCache, - type FileExplorerRow, - type MobileDirEntry + type DirectoryState, + type FileExplorerRow } from './file-tree' import type { RpcSuccess } from '../transport/types' import { colors } from '../theme/mobile-theme' import { beginDirectoryLoad, createDirectoryLoadRevisions, + forgetDirectoryLoadBranches, isCurrentDirectoryLoad, resetDirectoryLoadRevisions, type DirectoryLoadRevisions @@ -36,6 +37,12 @@ import { import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' import { MobileFileExplorerRow } from './mobile-file-explorer-row' import { navigateToMobileFilePreview } from './mobile-file-preview-navigation' +import { + MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE, + parseBoundedMobileDirectoryEntries, + removeEvictedExpandedPaths, + retainMobileDirectoryState +} from './mobile-directory-cache-retention' export function MobileFileExplorerPanel(props: { hostId: string @@ -54,13 +61,48 @@ export function MobileFileExplorerPanel(props: { const directoryLoadRevisionsRef = useRef(createDirectoryLoadRevisions()) const pendingDirectoryRetriesRef = useRef>(new Set()) const directoryCacheRef = useRef({}) + const directoryCacheAccessRef = useRef(0) const [directoryCache, setDirectoryCache] = useState({}) const [expanded, setExpanded] = useState>(() => new Set()) + const expandedRef = useRef(expanded) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [legacyListTruncated, setLegacyListTruncated] = useState(false) const worktreeLabel = getWorktreeLabel(name, worktreeId) + const commitDirectoryState = useCallback( + (relativePath: string, state: DirectoryState): boolean => { + const retained = retainMobileDirectoryState( + directoryCacheRef.current, + relativePath, + { ...state, lastAccess: ++directoryCacheAccessRef.current }, + expandedRef.current + ) + if (!retained.admitted) { + return false + } + directoryCacheRef.current = retained.cache + setDirectoryCache(retained.cache) + if (retained.evictedPaths.length > 0) { + forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, retained.evictedPaths) + for (const pendingPath of pendingDirectoryRetriesRef.current) { + if ( + retained.evictedPaths.some( + (evicted) => pendingPath === evicted || pendingPath.startsWith(`${evicted}/`) + ) + ) { + pendingDirectoryRetriesRef.current.delete(pendingPath) + } + } + const nextExpanded = removeEvictedExpandedPaths(expandedRef.current, retained.evictedPaths) + expandedRef.current = nextExpanded + setExpanded(nextExpanded) + } + return true + }, + [] + ) + const loadDirectory = useCallback( async (relativePath: string) => { const scope = scopeRef.current @@ -77,13 +119,10 @@ export function MobileFileExplorerPanel(props: { // Why: transient reconnects should not blank an already browsable tree. setError(hasLoadedRoot ? null : message) } else { - setDirectoryCache((prev) => ({ - ...prev, - [relativePath]: { - entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], - error: message - } - })) + commitDirectoryState(relativePath, { + entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [], + error: message + }) } return } @@ -98,13 +137,16 @@ export function MobileFileExplorerPanel(props: { } setError(null) } - setDirectoryCache((prev) => ({ - ...prev, - [relativePath]: { - entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], - loading: true - } - })) + const admittedLoading = commitDirectoryState(relativePath, { + entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [], + loading: true + }) + if (!admittedLoading) { + forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, [relativePath]) + setLoading(false) + setError(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE) + return + } try { const response = await client.sendRequest('files.readDir', { @@ -133,7 +175,9 @@ export function MobileFileExplorerPanel(props: { return } const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult - setDirectoryCache(directoryCacheFromFileList(legacyResult.files)) + const legacyCache = directoryCacheFromFileList(legacyResult.files) + directoryCacheRef.current = legacyCache + setDirectoryCache(legacyCache) // Why: the capped list silently omits files past the cap — keep // the legacy explorer's "Showing first 5000" note. setLegacyListTruncated(legacyResult.truncated) @@ -150,14 +194,13 @@ export function MobileFileExplorerPanel(props: { ) { return } - const entries = (response as RpcSuccess).result as MobileDirEntry[] + const entries = parseBoundedMobileDirectoryEntries((response as RpcSuccess).result) if (rootLoad) { setLegacyListTruncated(false) } - setDirectoryCache((prev) => ({ - ...prev, - [relativePath]: { entries } - })) + if (!commitDirectoryState(relativePath, { entries })) { + throw new Error(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE) + } } catch (err) { if ( !isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) @@ -170,13 +213,10 @@ export function MobileFileExplorerPanel(props: { // only a cold load surfaces the full-screen error. setError(hadLoadedRoot ? null : message) } else { - setDirectoryCache((prev) => ({ - ...prev, - [relativePath]: { - entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], - error: message - } - })) + commitDirectoryState(relativePath, { + entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [], + error: message + }) } } finally { if ( @@ -187,7 +227,7 @@ export function MobileFileExplorerPanel(props: { } } }, - [client, connState, worktreeId] + [client, commitDirectoryState, connState, worktreeId] ) useEffect(() => { @@ -195,17 +235,15 @@ export function MobileFileExplorerPanel(props: { resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current) pendingDirectoryRetriesRef.current.clear() directoryCacheRef.current = {} + directoryCacheAccessRef.current = 0 setDirectoryCache({}) - setExpanded(new Set()) + expandedRef.current = new Set() + setExpanded(expandedRef.current) setLoading(true) setError(null) setLegacyListTruncated(false) }, [scope]) - useEffect(() => { - directoryCacheRef.current = directoryCache - }, [directoryCache]) - useEffect(() => { void loadDirectory('') }, [hostId, loadDirectory]) @@ -228,21 +266,21 @@ export function MobileFileExplorerPanel(props: { const toggleDirectory = useCallback( (relativePath: string) => { - setExpanded((prev) => { - const next = new Set(prev) - if (next.has(relativePath)) { - next.delete(relativePath) - } else { - next.add(relativePath) - } - return next - }) + const wasExpanded = expandedRef.current.has(relativePath) + const nextExpanded = new Set(expandedRef.current) + if (wasExpanded) { + nextExpanded.delete(relativePath) + } else { + nextExpanded.add(relativePath) + } + expandedRef.current = nextExpanded + setExpanded(nextExpanded) const state = getDirectoryCacheState(directoryCache, relativePath) - if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) { + if (!wasExpanded && !state?.loading && (!state?.entries || state.error)) { void loadDirectory(relativePath) } }, - [directoryCache, expanded, loadDirectory] + [directoryCache, loadDirectory] ) const retryDirectory = useCallback( diff --git a/mobile/src/files/directory-load-revisions.test.ts b/mobile/src/files/directory-load-revisions.test.ts index 86ebfc3c0e8..b36f88c7022 100644 --- a/mobile/src/files/directory-load-revisions.test.ts +++ b/mobile/src/files/directory-load-revisions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { beginDirectoryLoad, createDirectoryLoadRevisions, + forgetDirectoryLoadBranches, isCurrentDirectoryLoad, resetDirectoryLoadRevisions, type DirectoryLoadRevisions @@ -41,4 +42,17 @@ describe('directory-load-revisions', () => { expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true) }) + + it('invalidates evicted branches without staling unrelated loads', () => { + const revisions = createDirectoryLoadRevisions() + const src = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src') + const nested = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src/lib') + const docs = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'docs') + + forgetDirectoryLoadBranches(revisions, ['src']) + + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', src)).toBe(false) + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', nested)).toBe(false) + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', docs)).toBe(true) + }) }) diff --git a/mobile/src/files/directory-load-revisions.ts b/mobile/src/files/directory-load-revisions.ts index 04153092bbe..584d2bc0aa9 100644 --- a/mobile/src/files/directory-load-revisions.ts +++ b/mobile/src/files/directory-load-revisions.ts @@ -19,6 +19,21 @@ export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions): revisions.revisionsByPath.clear() } +export function forgetDirectoryLoadBranches( + revisions: DirectoryLoadRevisions, + relativePaths: readonly string[] +): void { + for (const loadedPath of revisions.revisionsByPath.keys()) { + if ( + relativePaths.some( + (relativePath) => loadedPath === relativePath || loadedPath.startsWith(`${relativePath}/`) + ) + ) { + revisions.revisionsByPath.delete(loadedPath) + } + } +} + export function beginDirectoryLoad( revisions: DirectoryLoadRevisions, scope: string, diff --git a/mobile/src/files/file-list-fallback.test.ts b/mobile/src/files/file-list-fallback.test.ts index a69affc5fe8..ad6fc7eacec 100644 --- a/mobile/src/files/file-list-fallback.test.ts +++ b/mobile/src/files/file-list-fallback.test.ts @@ -1,7 +1,34 @@ import { describe, expect, it } from 'vitest' -import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' +import { + directoryCacheFromFileList, + isMobileMethodUnavailableError, + LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES, + LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES, + LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES, + LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE, + LEGACY_MOBILE_FILE_LIST_MAX_FILES, + LEGACY_MOBILE_FILE_PATH_MAX_BYTES, + LEGACY_MOBILE_FILE_PATH_MAX_DEPTH, + type LegacyMobileFileEntry +} from './file-list-fallback' import { getDirectoryCacheState } from './file-tree' +const RETAINED_NODE_ESTIMATE_BYTES = 64 + +function file(relativePath: string): LegacyMobileFileEntry { + return { relativePath, basename: relativePath, kind: 'text' } +} + +function directoryChainPath(prefix: string, directories: number): string { + const names = Array.from({ length: directories }, (_, index) => (index === 0 ? prefix : 'd')) + return `${names.join('/')}/file` +} + +function fixedLengthUniqueName(index: number, length: number): string { + const suffix = `-${index.toString(36)}` + return `${'x'.repeat(length - suffix.length)}${suffix}` +} + describe('isMobileMethodUnavailableError', () => { it('detects old-desktop allowlist and missing-method failures', () => { expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) @@ -20,9 +47,9 @@ describe('isMobileMethodUnavailableError', () => { describe('directoryCacheFromFileList', () => { it('synthesizes every ancestor directory from flat paths', () => { const cache = directoryCacheFromFileList([ - { relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' }, - { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }, - { relativePath: 'README.md', basename: 'README.md', kind: 'text' } + file('src/lib/util.ts'), + file('src/app.ts'), + file('README.md') ]) expect(cache['']?.entries).toEqual( expect.arrayContaining([ @@ -39,11 +66,27 @@ describe('directoryCacheFromFileList', () => { expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }]) }) - it('keeps a name a directory when it appears as both file and dir prefix', () => { + it('preserves empty-segment filtering and first-seen entry order', () => { const cache = directoryCacheFromFileList([ - { relativePath: 'src', basename: 'src', kind: 'text' }, - { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' } + file('//src///lib//util.ts//'), + file('/README.md'), + file('src/app.ts'), + file('///') ]) + + expect(cache['']?.entries).toEqual([ + { name: 'src', isDirectory: true }, + { name: 'README.md', isDirectory: false } + ]) + expect(cache['src']?.entries).toEqual([ + { name: 'lib', isDirectory: true }, + { name: 'app.ts', isDirectory: false } + ]) + expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }]) + }) + + it('keeps a name a directory when it appears as both file and dir prefix', () => { + const cache = directoryCacheFromFileList([file('src'), file('src/app.ts')]) expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }]) }) @@ -53,13 +96,112 @@ describe('directoryCacheFromFileList', () => { }) it('stores a __proto__ directory as an own key instead of mutating the prototype', () => { - const cache = directoryCacheFromFileList([ - { relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' } - ]) + const cache = directoryCacheFromFileList([file('__proto__/pollute.js')]) expect(Object.getPrototypeOf(cache)).toBe(Object.prototype) expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }]) expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([ { name: 'pollute.js', isDirectory: false } ]) }) + + it('accepts a delimiter-heavy path at the exact byte cap without retaining empty segments', () => { + const cache = directoryCacheFromFileList([file('/'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES))]) + expect(cache['']?.entries).toEqual([]) + }) + + it('measures the per-file path cap in UTF-8 bytes and rejects one byte over', () => { + const exactPath = 'é'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES / 2) + expect(directoryCacheFromFileList([file(exactPath)])['']?.entries).toEqual([ + { name: exactPath, isDirectory: false } + ]) + expect(() => directoryCacheFromFileList([file(`${exactPath}a`)])).toThrow( + LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE + ) + }) + + it('accepts the exact path depth and rejects the next segment', () => { + const exactPath = Array.from({ length: LEGACY_MOBILE_FILE_PATH_MAX_DEPTH }, () => 'd').join('/') + const parentPath = exactPath.slice(0, exactPath.lastIndexOf('/')) + expect( + getDirectoryCacheState(directoryCacheFromFileList([file(exactPath)]), parentPath) + ).toEqual({ + entries: [{ name: 'd', isDirectory: false }] + }) + expect(() => directoryCacheFromFileList([file(`${exactPath}/overflow`)])).toThrow( + LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE + ) + }) + + it('rejects a response above the desktop files.list record cap', () => { + const files = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES + 1 }, () => file('')) + expect(() => directoryCacheFromFileList(files)).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE) + }) + + it('accepts the exact directory cap and rejects one additional directory', () => { + const fullChains = Math.floor((LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1) / 255) + const remainingDirectories = LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1 - fullChains * 255 + const files = Array.from({ length: fullChains }, (_, index) => + file(directoryChainPath(`root-${index}`, 255)) + ) + if (remainingDirectories > 0) { + files.push(file(directoryChainPath('tail', remainingDirectories))) + } + + expect(Object.keys(directoryCacheFromFileList(files))).toHaveLength( + LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES + ) + expect(() => + directoryCacheFromFileList([...files, file(directoryChainPath('overflow', 1))]) + ).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE) + }) + + it('accepts the exact aggregate entry cap and rejects one additional entry', () => { + const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) => + file(`root-${index}/a/b/file`) + ) + const overflowFiles = exactFiles.map((entry, index) => + index === 0 ? file('root-0/a/b/c/file') : entry + ) + + const cache = directoryCacheFromFileList(exactFiles) + const entryCount = Object.values(cache).reduce( + (total, state) => total + (state?.entries.length ?? 0), + 0 + ) + expect(entryCount).toBe(LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES) + expect(() => directoryCacheFromFileList(overflowFiles)).toThrow( + LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE + ) + }) + + it('accepts the exact retained-byte budget and rejects two bytes over', () => { + const namesBytes = + LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - + RETAINED_NODE_ESTIMATE_BYTES - + LEGACY_MOBILE_FILE_LIST_MAX_FILES * RETAINED_NODE_ESTIMATE_BYTES + const namesCharacters = namesBytes / 2 + const baseLength = Math.floor(namesCharacters / LEGACY_MOBILE_FILE_LIST_MAX_FILES) + const longerNames = namesCharacters % LEGACY_MOBILE_FILE_LIST_MAX_FILES + const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) => { + const length = baseLength + (index < longerNames ? 1 : 0) + return file(fixedLengthUniqueName(index, length)) + }) + const overflowFiles = exactFiles.map((entry, index) => + index === 0 ? file(`${entry.relativePath}x`) : entry + ) + + expect(directoryCacheFromFileList(exactFiles)['']?.entries).toHaveLength( + LEGACY_MOBILE_FILE_LIST_MAX_FILES + ) + expect(() => directoryCacheFromFileList(overflowFiles)).toThrow( + LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE + ) + }) + + it('rejects malformed legacy response shapes with a clear error', () => { + expect(() => directoryCacheFromFileList({ files: [] })).toThrow('invalid legacy file list') + expect(() => directoryCacheFromFileList([{ relativePath: 42 }])).toThrow( + 'invalid legacy file list' + ) + }) }) diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/file-list-fallback.ts index ef3041029af..ef46aa6de85 100644 --- a/mobile/src/files/file-list-fallback.ts +++ b/mobile/src/files/file-list-fallback.ts @@ -1,8 +1,22 @@ // Fallback for desktops that predate files.readDir in the mobile RPC // allowlist: synthesize the lazy directory cache from the flat, capped // files.list result so the Files tab stays browsable against old desktops. +import { getUtf8ByteLengthForCodePoint } from '../../../src/shared/memory-safety/utf8-byte-limits' import type { DirectoryCache, MobileDirEntry } from './file-tree' +// Why: each flat legacy path can amplify into many retained ancestor records. +export const LEGACY_MOBILE_FILE_LIST_MAX_FILES = 5_000 +export const LEGACY_MOBILE_FILE_PATH_MAX_BYTES = 16 * 1024 +export const LEGACY_MOBILE_FILE_PATH_MAX_DEPTH = 256 +export const LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES = 16_384 +export const LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES = 20_000 +export const LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024 +export const LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE = + 'This legacy file list is too large to show safely on mobile. Update Orca Desktop to browse it folder by folder.' + +const DIRECTORY_RETAINED_BYTES = 64 +const ENTRY_RETAINED_BYTES = 64 + export type LegacyMobileFileEntry = { relativePath: string basename: string @@ -15,6 +29,13 @@ export type LegacyFilesListResult = { truncated: boolean } +type LegacyFileCacheBuildState = { + childrenByDir: Map> + directories: number + entries: number + retainedBytes: number +} + // Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: // 'forbidden' = method exists but is not mobile-allowlisted on the old // desktop; 'method_not_found' = desktop predates the method entirely. @@ -29,45 +50,156 @@ export function isMobileMethodUnavailableError( ) } -export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache { - const childrenByDir = new Map>() - const ensureDir = (path: string): Map => { - let children = childrenByDir.get(path) - if (!children) { - children = new Map() - childrenByDir.set(path, children) - } - return children +export function directoryCacheFromFileList(files: unknown): DirectoryCache { + if (!Array.isArray(files)) { + throw new Error('Desktop returned an invalid legacy file list.') } - ensureDir('') + if (files.length > LEGACY_MOBILE_FILE_LIST_MAX_FILES) { + throwLegacyFileListLimitError() + } + + const state: LegacyFileCacheBuildState = { + childrenByDir: new Map(), + directories: 0, + entries: 0, + retainedBytes: 0 + } + ensureDirectory(state, '') for (const file of files) { - const parts = file.relativePath.split('/').filter(Boolean) - let parentPath = '' - parts.forEach((name, index) => { - const isDirectory = index < parts.length - 1 - const children = ensureDir(parentPath) - children.set(name, children.get(name) === true || isDirectory) - parentPath = parentPath ? `${parentPath}/${name}` : name - if (isDirectory) { - ensureDir(parentPath) + const relativePath = getLegacyRelativePath(file) + if (relativePath === null) { + throw new Error('Desktop returned an invalid legacy file list.') + } + addFilePath(state, relativePath, measureBoundedPathDepth(relativePath)) + } + return createDirectoryCache(state.childrenByDir) +} + +function getLegacyRelativePath(value: unknown): string | null { + if (value === null || typeof value !== 'object') { + return null + } + const relativePath = (value as { relativePath?: unknown }).relativePath + return typeof relativePath === 'string' ? relativePath : null +} + +function measureBoundedPathDepth(relativePath: string): number { + let bytes = 0 + let depth = 0 + let insideSegment = false + for (let index = 0; index < relativePath.length; index += 1) { + const codePoint = relativePath.codePointAt(index) ?? 0 + bytes += getUtf8ByteLengthForCodePoint(codePoint) + if (bytes > LEGACY_MOBILE_FILE_PATH_MAX_BYTES) { + throwLegacyFileListLimitError() + } + if (codePoint === 47) { + if (insideSegment) { + depth += 1 + assertPathDepth(depth) } + insideSegment = false + } else { + insideSegment = true + } + if (codePoint > 0xffff) { + index += 1 + } + } + if (insideSegment) { + depth += 1 + assertPathDepth(depth) + } + return depth +} + +function assertPathDepth(depth: number): void { + if (depth > LEGACY_MOBILE_FILE_PATH_MAX_DEPTH) { + throwLegacyFileListLimitError() + } +} + +function addFilePath(state: LegacyFileCacheBuildState, relativePath: string, depth: number): void { + let parentPath = '' + let segmentStart = 0 + let segmentIndex = 0 + for (let cursor = 0; cursor <= relativePath.length; cursor += 1) { + if (cursor < relativePath.length && relativePath.charCodeAt(cursor) !== 47) { + continue + } + if (cursor > segmentStart) { + segmentIndex += 1 + const name = relativePath.slice(segmentStart, cursor) + const isDirectory = segmentIndex < depth + addDirectoryEntry(state, parentPath, name, isDirectory) + if (isDirectory) { + parentPath = parentPath ? `${parentPath}/${name}` : name + ensureDirectory(state, parentPath) + } + } + segmentStart = cursor + 1 + } +} + +function ensureDirectory(state: LegacyFileCacheBuildState, path: string): void { + if (state.childrenByDir.has(path)) { + return + } + const retainedBytes = path.length * 2 + DIRECTORY_RETAINED_BYTES + if ( + state.directories >= LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES || + state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes + ) { + throwLegacyFileListLimitError() + } + state.childrenByDir.set(path, new Map()) + state.directories += 1 + state.retainedBytes += retainedBytes +} + +function addDirectoryEntry( + state: LegacyFileCacheBuildState, + parentPath: string, + name: string, + isDirectory: boolean +): void { + const children = state.childrenByDir.get(parentPath) + if (!children) { + throw new Error('Legacy file cache builder lost its parent directory.') + } + const existing = children.get(name) + if (existing) { + existing.isDirectory ||= isDirectory + return + } + const retainedBytes = name.length * 2 + ENTRY_RETAINED_BYTES + if ( + state.entries >= LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES || + state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes + ) { + throwLegacyFileListLimitError() + } + children.set(name, { name, isDirectory }) + state.entries += 1 + state.retainedBytes += retainedBytes +} + +function createDirectoryCache( + childrenByDir: ReadonlyMap> +): DirectoryCache { + const cache: DirectoryCache = {} + for (const [path, children] of childrenByDir) { + // Why: assignment to a '__proto__' path would invoke its legacy setter. + Object.defineProperty(cache, path, { + configurable: true, + enumerable: true, + value: { entries: Array.from(children.values()) }, + writable: true }) } - // Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the - // object's prototype instead of storing the directory; fromEntries always - // creates own keys. - return Object.fromEntries( - Array.from(childrenByDir, ([path, children]) => [ - path, - { - entries: Array.from( - children, - ([name, isDirectory]): MobileDirEntry => ({ - name, - isDirectory - }) - ) - } - ]) - ) + return cache +} + +function throwLegacyFileListLimitError(): never { + throw new Error(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE) } diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index a9283cfa457..8a8a2170e07 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -22,6 +22,7 @@ export type DirectoryState = { entries: MobileDirEntry[] loading?: boolean error?: string + lastAccess?: number } export type DirectoryCache = Record diff --git a/mobile/src/files/mobile-diff-image-preview.test.ts b/mobile/src/files/mobile-diff-image-preview.test.ts index e63f532b28d..a7ae1cdc185 100644 --- a/mobile/src/files/mobile-diff-image-preview.test.ts +++ b/mobile/src/files/mobile-diff-image-preview.test.ts @@ -1,44 +1,57 @@ import { describe, expect, it } from 'vitest' import { mobileDiffImageDataUri } from './mobile-diff-image-preview' +function pngBase64(width = 1): string { + const bytes = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) + bytes.writeUInt32BE(13, 8) + bytes.write('IHDR', 12, 'ascii') + bytes.writeUInt32BE(width, 16) + bytes.writeUInt32BE(1, 20) + return bytes.toString('base64') +} + describe('mobileDiffImageDataUri', () => { it('renders a modified image diff from the post-change bytes', () => { + const modifiedContent = pngBase64(2) expect( mobileDiffImageDataUri({ kind: 'binary', originalContent: 'b2xk', - modifiedContent: 'bmV3', + modifiedContent, isImage: true, mimeType: 'image/png' }) - ).toBe('data:image/png;base64,bmV3') + ).toBe(`data:image/png;base64,${modifiedContent}`) }) it('renders an added image diff (no original) from the modified bytes', () => { + const modifiedContent = pngBase64() expect( mobileDiffImageDataUri({ kind: 'binary', originalContent: '', - modifiedContent: 'bmV3', + modifiedContent, isImage: true, mimeType: 'image/png' }) - ).toBe('data:image/png;base64,bmV3') + ).toBe(`data:image/png;base64,${modifiedContent}`) }) it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => { + const originalContent = pngBase64() expect( mobileDiffImageDataUri({ kind: 'binary', - originalContent: 'b2xk', + originalContent, originalIsBinary: true, modifiedContent: '', modifiedIsBinary: false, modifiedDeleted: true, isImage: true, - mimeType: 'image/jpeg' + mimeType: 'image/png' }) - ).toBe('data:image/jpeg;base64,b2xk') + ).toBe(`data:image/png;base64,${originalContent}`) }) // The reviewer's read-failure case: a relay/SSH read returns an empty modified diff --git a/mobile/src/files/mobile-directory-cache-retention.test.ts b/mobile/src/files/mobile-directory-cache-retention.test.ts new file mode 100644 index 00000000000..421d995492d --- /dev/null +++ b/mobile/src/files/mobile-directory-cache-retention.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { DirectoryCache, DirectoryState } from './file-tree' +import { + MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES, + parseBoundedMobileDirectoryEntries, + removeEvictedExpandedPaths, + retainMobileDirectoryState +} from './mobile-directory-cache-retention' + +function state(name: string, lastAccess: number): DirectoryState { + return { entries: [{ name, isDirectory: false }], lastAccess } +} + +describe('mobile directory cache retention', () => { + it('keeps the cache bounded across an unlimited sequence of visited directories', () => { + let cache: DirectoryCache = { '': state('root', 0) } + + for (let index = 1; index <= MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3; index++) { + const result = retainMobileDirectoryState( + cache, + `dir-${index}`, + state(`file-${index}`, index), + new Set() + ) + expect(result.admitted).toBe(true) + cache = result.cache + } + + expect(Object.keys(cache)).toHaveLength(MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES) + expect(cache['']).toBeDefined() + expect(cache['dir-1']).toBeUndefined() + expect(cache[`dir-${MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3}`]).toBeDefined() + }) + + it('evicts a collapsed branch before an older expanded branch', () => { + const cache: DirectoryCache = { + '': state('root', 0), + expanded: state('expanded', 1), + collapsed: state('collapsed', 2) + } + + const result = retainMobileDirectoryState( + cache, + 'new', + state('new', 3), + new Set(['expanded']), + { directories: 3, entries: 100, retainedBytes: 10_000 } + ) + + expect(result.cache.expanded).toBeDefined() + expect(result.cache.collapsed).toBeUndefined() + expect(result.evictedPaths).toEqual(['collapsed']) + }) + + it('evicts old content when aggregate entry retention reaches its cap', () => { + const cache: DirectoryCache = { + '': state('root', 0), + old: state('old', 1) + } + + const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), { + directories: 10, + entries: 2, + retainedBytes: 10_000 + }) + + expect(result.cache.old).toBeUndefined() + expect(result.cache.new).toBeDefined() + }) + + it('evicts old content when aggregate retained bytes reach their cap', () => { + const cache: DirectoryCache = { + '': state('root', 0), + old: state('x'.repeat(100), 1) + } + + const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), { + directories: 10, + entries: 100, + retainedBytes: 300 + }) + + expect(result.cache.old).toBeUndefined() + expect(result.cache.new).toBeDefined() + }) + + it('collapses expanded descendants whose cached branch was evicted', () => { + expect([...removeEvictedExpandedPaths(new Set(['src', 'src/lib', 'docs']), ['src'])]).toEqual([ + 'docs' + ]) + }) + + it('rejects malformed listings instead of retaining untrusted response shapes', () => { + expect(() => parseBoundedMobileDirectoryEntries([{ name: 'src' }])).toThrow( + 'invalid folder listing' + ) + }) +}) diff --git a/mobile/src/files/mobile-directory-cache-retention.ts b/mobile/src/files/mobile-directory-cache-retention.ts new file mode 100644 index 00000000000..b6ec4b9748b --- /dev/null +++ b/mobile/src/files/mobile-directory-cache-retention.ts @@ -0,0 +1,156 @@ +import { + assertMobileFileDirectoryWithinLimit, + estimateMobileDirectoryEntryBytes, + MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE, + MOBILE_FILE_DIRECTORY_MAX_ENTRIES +} from '../../../src/shared/mobile-file-directory-limit' +import type { DirectoryCache, DirectoryState, MobileDirEntry } from './file-tree' + +// Why: old collapsed branches can reload on demand once explorer metadata reaches a phone-safe ceiling. +export const MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES = 128 +export const MOBILE_DIRECTORY_CACHE_MAX_ENTRIES = 25_000 +export const MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024 +export const MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE = + 'Too many folders are open to load this folder safely. Close another folder and retry.' + +type CacheLimits = { + directories: number + entries: number + retainedBytes: number +} + +type RetentionResult = { + cache: DirectoryCache + evictedPaths: string[] + admitted: boolean +} + +const DEFAULT_LIMITS: CacheLimits = { + directories: MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES, + entries: MOBILE_DIRECTORY_CACHE_MAX_ENTRIES, + retainedBytes: MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES +} + +export function parseBoundedMobileDirectoryEntries(value: unknown): MobileDirEntry[] { + if (!Array.isArray(value)) { + throw new Error('Desktop returned an invalid folder listing.') + } + if (value.length > MOBILE_FILE_DIRECTORY_MAX_ENTRIES) { + throw new Error(MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE) + } + for (const entry of value) { + if (!isMobileDirectoryEntry(entry)) { + throw new Error('Desktop returned an invalid folder listing.') + } + } + const entries = value as MobileDirEntry[] + assertMobileFileDirectoryWithinLimit(entries) + return entries +} + +export function retainMobileDirectoryState( + cache: DirectoryCache, + relativePath: string, + state: DirectoryState, + expandedPaths: ReadonlySet, + limits: CacheLimits = DEFAULT_LIMITS +): RetentionResult { + const next: DirectoryCache = { ...cache, [relativePath]: state } + const evictedPaths: string[] = [] + const essentialPaths = directoryAncestors(relativePath) + + while (cacheExceedsLimits(next, limits)) { + const victim = selectEvictionPath(next, expandedPaths, essentialPaths) + if (victim === null) { + return { cache, evictedPaths: [], admitted: false } + } + for (const path of Object.keys(next)) { + if (path === victim || path.startsWith(`${victim}/`)) { + delete next[path] + evictedPaths.push(path) + } + } + } + return { cache: next, evictedPaths, admitted: true } +} + +export function removeEvictedExpandedPaths( + expandedPaths: ReadonlySet, + evictedPaths: readonly string[] +): Set { + if (evictedPaths.length === 0) { + return new Set(expandedPaths) + } + return new Set( + [...expandedPaths].filter( + (expanded) => + !evictedPaths.some((evicted) => expanded === evicted || expanded.startsWith(`${evicted}/`)) + ) + ) +} + +function isMobileDirectoryEntry(value: unknown): value is MobileDirEntry { + if (!value || typeof value !== 'object') { + return false + } + const entry = value as Record + return ( + typeof entry.name === 'string' && + typeof entry.isDirectory === 'boolean' && + (entry.isSymlink === undefined || typeof entry.isSymlink === 'boolean') + ) +} + +function directoryAncestors(relativePath: string): Set { + const ancestors = new Set(['', relativePath]) + let cursor = relativePath + while (cursor.includes('/')) { + cursor = cursor.slice(0, cursor.lastIndexOf('/')) + ancestors.add(cursor) + } + return ancestors +} + +function selectEvictionPath( + cache: DirectoryCache, + expandedPaths: ReadonlySet, + essentialPaths: ReadonlySet +): string | null { + const candidates = Object.keys(cache) + .filter((path) => !essentialPaths.has(path)) + .sort((left, right) => accessOrder(cache, left) - accessOrder(cache, right)) + return candidates.find((path) => !expandedPaths.has(path)) ?? candidates[0] ?? null +} + +function accessOrder(cache: DirectoryCache, path: string): number { + return cache[path]?.lastAccess ?? 0 +} + +function cacheExceedsLimits(cache: DirectoryCache, limits: CacheLimits): boolean { + const paths = Object.keys(cache) + if (paths.length > limits.directories) { + return true + } + let entries = 0 + let retainedBytes = 0 + for (const path of paths) { + const state = cache[path] + if (!state) { + continue + } + entries += state.entries.length + retainedBytes += estimateDirectoryStateBytes(path, state) + if (entries > limits.entries || retainedBytes > limits.retainedBytes) { + return true + } + } + return false +} + +function estimateDirectoryStateBytes(path: string, state: DirectoryState): number { + let bytes = path.length * 2 + (state.error?.length ?? 0) * 2 + 64 + for (const entry of state.entries) { + bytes += estimateMobileDirectoryEntryBytes(entry) + } + return bytes +} diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/mobile-file-preview-request.test.ts index f7f77fabd19..185962bad72 100644 --- a/mobile/src/files/mobile-file-preview-request.test.ts +++ b/mobile/src/files/mobile-file-preview-request.test.ts @@ -28,6 +28,16 @@ function clientWithResponses(responses: RpcResponse[]) { } } +function pngBase64(width = 1, height = 1): string { + const bytes = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) + bytes.writeUInt32BE(13, 8) + bytes.write('IHDR', 12, 'ascii') + bytes.writeUInt32BE(width, 16) + bytes.writeUInt32BE(height, 20) + return bytes.toString('base64') +} + describe('mobile-file-preview-request', () => { it('selects readPreview for raster images and read for text-like files', () => { expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({ @@ -45,14 +55,13 @@ describe('mobile-file-preview-request', () => { }) it('loads images through readPreview and never calls files.open', async () => { - const client = clientWith( - ok({ content: 'aW1hZ2U=', isBinary: true, isImage: true, mimeType: 'image/png' }) - ) + const content = pngBase64() + const client = clientWith(ok({ content, isBinary: true, isImage: true, mimeType: 'image/png' })) await expect(loadMobileFilePreview(client, 'wt-1', 'assets/logo.png')).resolves.toEqual({ status: 'ready', kind: 'image', - dataUri: 'data:image/png;base64,aW1hZ2U=' + dataUri: `data:image/png;base64,${content}` }) expect(client.sendRequest).toHaveBeenCalledWith('files.readPreview', { worktree: 'id:wt-1', @@ -527,9 +536,9 @@ describe('mobile-file-preview-request', () => { }) it.each([ - ['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }], - ['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }], - ['missing mimeType', { content: 'aW1hZ2U=', isBinary: true, isImage: true }], + ['missing isBinary', { content: pngBase64(), isImage: true, mimeType: 'image/png' }], + ['missing isImage', { content: pngBase64(), isBinary: true, mimeType: 'image/png' }], + ['missing mimeType', { content: pngBase64(), isBinary: true, isImage: true }], ['empty content', { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }] ])('rejects invalid image preview results: %s', (_label, result) => { expect(normalizeMobileFilePreviewResponse('assets/logo.png', ok(result))).toEqual({ @@ -539,6 +548,24 @@ describe('mobile-file-preview-request', () => { }) }) + it('rejects an oversized raster response before React Native receives a data URI', () => { + expect( + normalizeMobileFilePreviewResponse( + 'assets/logo.png', + ok({ + content: pngBase64(32_769, 1), + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + ) + ).toEqual({ + status: 'error', + message: 'Binary preview unavailable', + reconnect: false + }) + }) + it('normalizes markdown, html, text, empty, and truncated reads', () => { expect( normalizeMobileFilePreviewResponse( diff --git a/mobile/src/files/mobile-file-preview-response.ts b/mobile/src/files/mobile-file-preview-response.ts index 0b7b1b23be8..8f57a92edf7 100644 --- a/mobile/src/files/mobile-file-preview-response.ts +++ b/mobile/src/files/mobile-file-preview-response.ts @@ -2,6 +2,7 @@ import { classifyMobileArtifact } from '../session/mobile-artifact-kind' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' import { isMarkdownPath } from './file-tree' import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' +import { buildImageDataUri } from '../../../src/shared/image-data-uri' export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text' @@ -117,10 +118,14 @@ function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult { ) { return previewError('binary_file') } + const dataUri = buildImageDataUri(preview.mimeType, preview.content) + if (!dataUri) { + return previewError('binary_file') + } return { status: 'ready', kind: 'image', - dataUri: `data:${preview.mimeType};base64,${preview.content}` + dataUri } } diff --git a/mobile/src/files/mobile-file-tab-doc.test.ts b/mobile/src/files/mobile-file-tab-doc.test.ts index a9570e6cf44..cfe5578a7e0 100644 --- a/mobile/src/files/mobile-file-tab-doc.test.ts +++ b/mobile/src/files/mobile-file-tab-doc.test.ts @@ -31,6 +31,16 @@ function clientOf(byMethod: Record): { const WT = { worktreeId: 'wt1' } +function pngBase64(width = 1): string { + const bytes = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) + bytes.writeUInt32BE(13, 8) + bytes.write('IHDR', 12, 'ascii') + bytes.writeUInt32BE(width, 16) + bytes.writeUInt32BE(1, 20) + return bytes.toString('base64') +} + describe('resolveMobileFileTabDoc', () => { it('renders a staged text diff', async () => { const client = clientOf({ @@ -46,11 +56,12 @@ describe('resolveMobileFileTabDoc', () => { }) it('renders an unstaged image diff from the modified bytes', async () => { + const modifiedContent = pngBase64() const client = clientOf({ 'git.diff': ok({ kind: 'binary', originalContent: 'b2xk', - modifiedContent: 'bmV3', + modifiedContent, modifiedIsBinary: true, isImage: true, mimeType: 'image/png' @@ -61,7 +72,11 @@ describe('resolveMobileFileTabDoc', () => { relativePath: 'm1.png', diffSource: 'unstaged' }) - expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) + expect(doc).toEqual({ + status: 'ready', + kind: 'image', + dataUri: `data:image/png;base64,${modifiedContent}` + }) }) it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => { @@ -88,11 +103,16 @@ describe('resolveMobileFileTabDoc', () => { }) it('renders a live image preview via files.readPreview', async () => { + const content = pngBase64() const client = clientOf({ - 'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' }) + 'files.readPreview': ok({ content, isImage: true, mimeType: 'image/png' }) }) const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' }) - expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) + expect(doc).toEqual({ + status: 'ready', + kind: 'image', + dataUri: `data:image/png;base64,${content}` + }) expect(client.calls).toEqual(['files.readPreview']) }) diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.test.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.test.ts new file mode 100644 index 00000000000..69fa92f0265 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-audio-chunk.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + enqueueMobileDictationAudioChunk, + MOBILE_DICTATION_MAX_PENDING_CHUNKS +} from './mobile-dictation-audio-chunk' +import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' + +describe('enqueueMobileDictationAudioChunk', () => { + it('accepts the exact pending-promise cap and rejects one over even for empty chunks', () => { + const sendRequest = vi.fn(() => new Promise(() => undefined)) + const failActiveDictation = vi.fn() + const pendingChunks = new Set>() + const queue = { + pendingChunks, + pendingAudioBudget: new MobileDictationPendingAudioBudget(), + shouldReleaseBudget: () => true, + failActiveDictation + } + const event = { data: new Uint8Array() } + + for (let index = 0; index < MOBILE_DICTATION_MAX_PENDING_CHUNKS; index += 1) { + enqueueMobileDictationAudioChunk( + { sendRequest } as unknown as RpcClient, + 'dictation', + event, + queue + ) + } + expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS) + expect(failActiveDictation).not.toHaveBeenCalled() + + enqueueMobileDictationAudioChunk( + { sendRequest } as unknown as RpcClient, + 'dictation', + event, + queue + ) + expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS) + expect(failActiveDictation).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.ts index 792292e6c16..947deee0e61 100644 --- a/mobile/src/hooks/mobile-dictation-audio-chunk.ts +++ b/mobile/src/hooks/mobile-dictation-audio-chunk.ts @@ -14,12 +14,21 @@ type MobileDictationAudioChunkQueue = { failActiveDictation: (dictationId: string, err: unknown) => void } +export const MOBILE_DICTATION_MAX_PENDING_CHUNKS = 256 + export function enqueueMobileDictationAudioChunk( client: RpcClient, dictationId: string, event: MicrophoneDataEvent, queue: MobileDictationAudioChunkQueue ): void { + if (queue.pendingChunks.size >= MOBILE_DICTATION_MAX_PENDING_CHUNKS) { + queue.failActiveDictation( + dictationId, + new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE) + ) + return + } const raw = event.data const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) const byteLength = bytes.byteLength diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index 803964040b5..36d39ceb5ec 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import type { AiVaultSession } from '../../../src/shared/ai-vault-types' -import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup' import { buildMobileAiVaultResumeLaunch, buildMobileAiVaultResumeCommand, @@ -121,50 +120,6 @@ describe('buildMobileAiVaultResumeCommand', () => { }) describe('buildMobileAiVaultResumeLaunch', () => { - it('preserves an arbitrary OMP transcript locator for later cold resume', () => { - const launch = buildMobileAiVaultResumeLaunch({ - session: session({ - agent: 'omp', - sessionId: 'omp-custom-1', - filePath: '/custom/omp-sessions/project/session.jsonl' - }), - hostPlatform: 'linux', - settings: { - agentDefaultArgs: { omp: '--model custom' }, - agentDefaultEnv: { omp: { OMP_PROFILE: 'custom' } } - } - }) - - expect(launch).toMatchObject({ - command: - "cd '/Users/ada/repo' && omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'", - env: { OMP_PROFILE: 'custom' }, - launchConfig: { - agentCommand: "omp '--model' 'custom'", - agentArgs: '--model custom', - agentEnv: { OMP_PROFILE: 'custom' }, - ompResumeFilePath: '/custom/omp-sessions/project/session.jsonl' - }, - launchAgent: 'omp' - }) - - const coldLaunch = buildAgentResumeStartupPlan({ - agent: 'omp', - providerSession: { key: 'session_id', id: 'omp-custom-1' }, - cmdOverrides: {}, - agentArgs: launch.launchConfig?.agentArgs, - agentEnv: launch.launchConfig?.agentEnv, - agentCommand: launch.launchConfig?.agentCommand, - ompResumeFilePath: launch.launchConfig?.ompResumeFilePath, - platform: 'linux' - }) - expect(coldLaunch).toMatchObject({ - launchCommand: - "omp '--model' 'custom' '--resume' '/custom/omp-sessions/project/session.jsonl'", - env: { OMP_PROFILE: 'custom' } - }) - }) - it('uses shared TUI startup planning for default args, env, and launch config', () => { const launch = buildMobileAiVaultResumeLaunch({ session: session({ diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index 24c3a92e41d..dce63378656 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -15,7 +15,6 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../src/shared/tui-agent-launch-defaults' -import { normalizeAiVaultResumeFilePath } from '../../../src/shared/ai-vault-resume-path' import type { TuiAgent } from '../../../src/shared/types' import { parseWslUncPath } from '../../../src/shared/wsl-paths' import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell' @@ -59,7 +58,7 @@ export function buildMobileAiVaultResumeCommand(args: { sessionId: args.session.sessionId, // Why: OMP resumes by absolute transcript path (custom OMP dir / WSL-store // sessions miss on an id lookup), so mobile forwards it like desktop does. - resumeFilePath: normalizeAiVaultResumeFilePath(args.session.filePath, args.hostPlatform), + resumeFilePath: args.session.filePath, cwd: args.session.cwd, platform: args.hostPlatform, commandOverride: args.commandOverride, @@ -98,7 +97,6 @@ export function buildMobileAiVaultResumeLaunch(args: { args.settings?.agentCmdOverrides ) const commandOverride = cmdOverrides[args.session.agent] ?? null - const resumeFilePath = normalizeAiVaultResumeFilePath(args.session.filePath, args.hostPlatform) if (isResumableTuiAgent(args.session.agent)) { const startupPlan = buildAgentResumeStartupPlan({ agent: args.session.agent, @@ -107,31 +105,17 @@ export function buildMobileAiVaultResumeLaunch(args: { platform: args.hostPlatform, shell, agentArgs: resolveTuiAgentLaunchArgs(args.session.agent, args.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings?.agentDefaultEnv), - ...(args.session.agent === 'omp' && resumeFilePath - ? { ompResumeFilePath: resumeFilePath } - : {}) + agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings?.agentDefaultEnv) }) if (startupPlan) { return { - command: - args.session.agent === 'omp' - ? buildMobileAiVaultResumeCommand({ - session: { - ...args.session, - ...(resumeFilePath ? { filePath: resumeFilePath } : {}) - }, - hostPlatform: args.hostPlatform, - hostTerminalWindowsShell: args.hostTerminalWindowsShell, - commandOverride: startupPlan.launchConfig.agentCommand - }) - : buildAiVaultResumeShellCommand({ - resumeCommand: startupPlan.launchCommand, - cwd: args.session.cwd, - platform: args.hostPlatform, - codexHome, - shell - }), + command: buildAiVaultResumeShellCommand({ + resumeCommand: startupPlan.launchCommand, + cwd: args.session.cwd, + platform: args.hostPlatform, + codexHome, + shell + }), ...(startupPlan.env ? { env: startupPlan.env } : {}), // Why: the resume command is typed into the created pane, so the bare // real-home override must strip Codex homes at pane spawn like desktop. diff --git a/mobile/src/session/mobile-image-base64-accumulator.test.ts b/mobile/src/session/mobile-image-base64-accumulator.test.ts new file mode 100644 index 00000000000..d6da669b7d3 --- /dev/null +++ b/mobile/src/session/mobile-image-base64-accumulator.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator' + +describe('MobileImageBase64Accumulator', () => { + it('preserves bytes delivered as 100,000 one-byte fragments', () => { + const accumulator = new MobileImageBase64Accumulator() + const expected = Buffer.alloc(100_000) + + for (let index = 0; index < expected.byteLength; index += 1) { + const value = index % 251 + expected[index] = value + accumulator.append(Uint8Array.of(value)) + } + + expect(accumulator.finish()).toBe(expected.toString('base64')) + }) +}) diff --git a/mobile/src/session/mobile-image-base64-accumulator.ts b/mobile/src/session/mobile-image-base64-accumulator.ts new file mode 100644 index 00000000000..079a4d89a0e --- /dev/null +++ b/mobile/src/session/mobile-image-base64-accumulator.ts @@ -0,0 +1,40 @@ +import { Buffer } from 'buffer' + +const MOBILE_IMAGE_BASE64_CHUNK_BYTES = 256 * 1024 - 1 + +export class MobileImageBase64Accumulator { + private readonly staging = new Uint8Array(MOBILE_IMAGE_BASE64_CHUNK_BYTES) + private readonly encodedChunks: string[] = [] + private stagingLength = 0 + + append(bytes: Uint8Array): void { + let offset = 0 + while (offset < bytes.byteLength) { + const copied = Math.min( + this.staging.byteLength - this.stagingLength, + bytes.byteLength - offset + ) + this.staging.set(bytes.subarray(offset, offset + copied), this.stagingLength) + this.stagingLength += copied + offset += copied + if (this.stagingLength === this.staging.byteLength) { + this.flushStaging() + } + } + } + + finish(): string { + this.flushStaging() + return this.encodedChunks.join('') + } + + private flushStaging(): void { + if (this.stagingLength === 0) { + return + } + this.encodedChunks.push( + Buffer.from(this.staging.subarray(0, this.stagingLength)).toString('base64') + ) + this.stagingLength = 0 + } +} diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts index a165e963329..1a582bde3fe 100644 --- a/mobile/src/session/mobile-image-source-picker.test.ts +++ b/mobile/src/session/mobile-image-source-picker.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image' vi.mock('expo-image-picker', () => ({ requestMediaLibraryPermissionsAsync: vi.fn(), @@ -7,6 +8,9 @@ vi.mock('expo-image-picker', () => ({ vi.mock('expo-document-picker', () => ({ getDocumentAsync: vi.fn() })) +vi.mock('expo-file-system', () => ({ + File: vi.fn() +})) import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker' @@ -15,17 +19,48 @@ const granted = { granted: true } as Awaited< > const denied = { granted: false } as typeof granted +function fileFactory( + chunks: Uint8Array[], + options?: { fileSize?: number; handleSize?: number | null; readError?: Error } +) { + const close = vi.fn() + const readBytes = vi.fn(() => { + if (options?.readError) { + throw options.readError + } + return chunks.shift() ?? new Uint8Array() + }) + const open = vi.fn(() => ({ + size: options?.handleSize ?? options?.fileSize ?? 0, + readBytes, + close + })) + const createFile = vi.fn(() => ({ size: options?.fileSize ?? 0, open })) + return { close, createFile, open, readBytes } +} + describe('pickMobileImage', () => { it('returns base64 from the photo library', async () => { + const bytes = new Uint8Array([0, 1, 2, 3]) + const file = fileFactory([bytes]) + const launchLibrary = vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///x.jpg', fileSize: bytes.length }] + }) const result = await pickMobileImage('library', { requestLibraryPermission: vi.fn().mockResolvedValue(granted), - launchLibrary: vi.fn().mockResolvedValue({ - canceled: false, - assets: [{ uri: 'file:///x.jpg', base64: 'AAAA' }] - }) + launchLibrary, + createFile: file.createFile }) - expect(result).toEqual({ base64: 'AAAA', uri: 'file:///x.jpg' }) + expect(result).toEqual({ + base64: Buffer.from(bytes).toString('base64'), + uri: 'file:///x.jpg' + }) + expect(launchLibrary).toHaveBeenCalledWith( + expect.objectContaining({ base64: false, allowsMultipleSelection: false }) + ) + expect(file.close).toHaveBeenCalledTimes(1) }) it('throws when photo library permission is denied', async () => { @@ -48,22 +83,25 @@ describe('pickMobileImage', () => { it('reads a picked file URI into base64 for the files source', async () => { const bytes = new Uint8Array([1, 2, 3, 4]) - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(bytes.buffer, { headers: { 'content-type': 'image/png' } })) + const file = fileFactory([bytes]) + const launchFiles = vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///doc.png', size: bytes.length }] + }) const result = await pickMobileImage('files', { - launchFiles: vi.fn().mockResolvedValue({ - canceled: false, - assets: [{ uri: 'file:///doc.png' }] - }) + launchFiles, + createFile: file.createFile }) expect(result).toEqual({ base64: Buffer.from(bytes).toString('base64'), uri: 'file:///doc.png' }) - fetchSpy.mockRestore() + expect(launchFiles).toHaveBeenCalledWith( + expect.objectContaining({ copyToCacheDirectory: true }) + ) + expect(file.close).toHaveBeenCalledTimes(1) }) it('returns null when the files picker is cancelled', async () => { @@ -73,4 +111,72 @@ describe('pickMobileImage', () => { expect(result).toBeNull() }) + + it('rejects a declared oversized asset before opening it', async () => { + const file = fileFactory([], { fileSize: 1 }) + await expect( + pickMobileImage('files', { + launchFiles: vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///huge.png', size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 }] + }), + createFile: file.createFile + }) + ).rejects.toThrow('Clipboard image is too large') + expect(file.createFile).not.toHaveBeenCalled() + expect(file.open).not.toHaveBeenCalled() + }) + + it('does not let stale size metadata bypass the bounded read', async () => { + const close = vi.fn() + const readBytes = vi.fn((length: number) => new Uint8Array(length)) + const createFile = vi.fn(() => ({ + size: 1, + open: () => ({ size: 1, readBytes, close }) + })) + + await expect( + pickMobileImage('library', { + requestLibraryPermission: vi.fn().mockResolvedValue(granted), + launchLibrary: vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///grew.png', fileSize: 1 }] + }), + createFile + }) + ).rejects.toThrow('Clipboard image is too large') + expect(readBytes).toHaveBeenLastCalledWith(1) + expect(close).toHaveBeenCalledTimes(1) + }) + + it('closes the file handle when a read fails', async () => { + const file = fileFactory([], { fileSize: 4, readError: new Error('read failed') }) + await expect( + pickMobileImage('files', { + launchFiles: vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///broken.png', size: 4 }] + }), + createFile: file.createFile + }) + ).rejects.toThrow('read failed') + expect(file.close).toHaveBeenCalledTimes(1) + }) + + it('preserves bytes across chunk boundaries that are not base64 aligned', async () => { + const chunks = [new Uint8Array([1]), new Uint8Array([2, 3]), new Uint8Array([4, 5])] + const file = fileFactory([...chunks], { fileSize: 5, handleSize: 5 }) + const result = await pickMobileImage('files', { + launchFiles: vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///chunked.png', size: 5 }] + }), + createFile: file.createFile + }) + expect(result).toEqual({ + base64: Buffer.from([1, 2, 3, 4, 5]).toString('base64'), + uri: 'file:///chunked.png' + }) + expect(file.close).toHaveBeenCalledTimes(1) + }) }) diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts index 7f510494618..bf9c529cc25 100644 --- a/mobile/src/session/mobile-image-source-picker.ts +++ b/mobile/src/session/mobile-image-source-picker.ts @@ -1,8 +1,12 @@ -// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro -// can't resolve Node's builtin in a React Native bundle. -import { Buffer } from 'buffer' import * as DocumentPicker from 'expo-document-picker' +import { File as FsFile } from 'expo-file-system' import * as ImagePicker from 'expo-image-picker' +import { + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, + assertClipboardImageBase64LengthWithinLimit, + assertClipboardImageByteLengthWithinLimit +} from '../../../src/shared/clipboard-image' +import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator' export type MobileImageSource = 'library' | 'files' @@ -21,18 +25,68 @@ export class ImageLibraryPermissionError extends Error { } } -// Why: expo-document-picker returns a file URI, not base64. Read it through -// fetch + Buffer so we match the base64 contract the upload pipeline expects -// without pulling in expo-file-system. -async function readUriAsBase64(uri: string): Promise { - const response = await fetch(uri) - const bytes = new Uint8Array(await response.arrayBuffer()) - return Buffer.from(bytes).toString('base64') +const MOBILE_IMAGE_READ_CHUNK_BYTES = 256 * 1024 + +type MobileImageFileHandle = { + readonly size: number | null + readBytes(length: number): Uint8Array + close(): void +} + +type MobileImageFile = { + readonly size: number + open(): MobileImageFileHandle +} + +export type MobileImageFileFactory = (uri: string) => MobileImageFile + +function defaultMobileImageFileFactory(uri: string): MobileImageFile { + return new FsFile(uri) +} + +async function readUriAsBase64( + uri: string, + declaredSize: number | undefined, + createFile: MobileImageFileFactory +): Promise { + if (typeof declaredSize === 'number' && Number.isFinite(declaredSize)) { + assertClipboardImageByteLengthWithinLimit(declaredSize) + } + + const file = createFile(uri) + assertClipboardImageByteLengthWithinLimit(file.size) + const handle = file.open() + try { + if (handle.size !== null) { + assertClipboardImageByteLengthWithinLimit(handle.size) + } + const accumulator = new MobileImageBase64Accumulator() + let bytesRead = 0 + while (bytesRead <= CLIPBOARD_IMAGE_MAX_SOURCE_BYTES) { + const requested = Math.min( + MOBILE_IMAGE_READ_CHUNK_BYTES, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES - bytesRead + 1 + ) + const bytes = handle.readBytes(requested) + if (bytes.byteLength === 0) { + break + } + bytesRead += bytes.byteLength + assertClipboardImageByteLengthWithinLimit(bytesRead) + accumulator.append(bytes) + } + const base64 = accumulator.finish() + assertClipboardImageBase64LengthWithinLimit(base64.length) + return base64 + } finally { + handle.close() + } } async function pickFromLibrary( requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync, - launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync + launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync, + createFile: MobileImageFileFactory = defaultMobileImageFileFactory ): Promise { const permission = await requestPermission() // Why: `granted` covers full + limited iOS access; only a hard denial blocks us. @@ -41,7 +95,7 @@ async function pickFromLibrary( } const result = await launch({ mediaTypes: ['images'], - base64: true, + base64: false, allowsMultipleSelection: false, quality: 1 }) @@ -49,7 +103,7 @@ async function pickFromLibrary( return null } const asset = result.assets[0] - const base64 = asset?.base64 ?? (asset?.uri ? await readUriAsBase64(asset.uri) : null) + const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null if (!base64) { return null } @@ -57,7 +111,8 @@ async function pickFromLibrary( } async function pickFromFiles( - launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync + launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync, + createFile: MobileImageFileFactory = defaultMobileImageFileFactory ): Promise { const result = await launch({ type: 'image/*', @@ -71,7 +126,8 @@ async function pickFromFiles( if (!asset?.uri) { return null } - return { base64: await readUriAsBase64(asset.uri), uri: asset.uri } + const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) + return base64 ? { base64, uri: asset.uri } : null } export async function pickMobileImage( @@ -80,10 +136,11 @@ export async function pickMobileImage( readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync readonly launchFiles?: typeof DocumentPicker.getDocumentAsync + readonly createFile?: MobileImageFileFactory } ): Promise { if (source === 'library') { - return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary) + return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile) } - return pickFromFiles(deps?.launchFiles) + return pickFromFiles(deps?.launchFiles, deps?.createFile) } diff --git a/mobile/src/session/mobile-session-file-doc-lifecycle.test.ts b/mobile/src/session/mobile-session-file-doc-lifecycle.test.ts new file mode 100644 index 00000000000..f29685b7bca --- /dev/null +++ b/mobile/src/session/mobile-session-file-doc-lifecycle.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { + MobileSessionFileDocLifecycle, + beginMobileFileDocLoad, + createMobileFileDocLifecycle, + finishMobileFileDocLoad, + forgetMobileFileDocTab, + reconcileMobileFileDocTabs, + removeMobileFileDoc, + resetMobileFileDocLifecycle, + retainLiveMobileFileDocs, + type MobileFileTabIdentity +} from './mobile-session-file-doc-lifecycle' +import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc' + +type TestFileDoc = MobileFileTabDoc | { status: 'loading' } | { status: 'error'; message: string } + +function fileTab(id: string, relativePath = `${id}.ts`): MobileFileTabIdentity { + return { + id, + filePath: `/repo/${relativePath}`, + relativePath, + mode: 'edit' + } +} + +describe('mobile session file document lifecycle', () => { + it('retains payloads only for file tabs in the accepted live snapshot', () => { + const lifecycle = createMobileFileDocLifecycle() + reconcileMobileFileDocTabs(lifecycle, [fileTab('live-a'), fileTab('live-b')]) + const docs = new Map([ + ['closed', { payload: 'x'.repeat(1_000_000) }], + ['live-a', { payload: 'a' }], + ['live-b', { payload: 'b' }] + ]) + + const retained = retainLiveMobileFileDocs(docs, lifecycle) + + expect([...retained.keys()]).toEqual(['live-a', 'live-b']) + expect(retained.get('live-a')).toBe(docs.get('live-a')) + }) + + it('deletes a successfully closed tab immediately', () => { + const lifecycle = createMobileFileDocLifecycle() + reconcileMobileFileDocTabs(lifecycle, [fileTab('closed')]) + forgetMobileFileDocTab(lifecycle, 'closed') + + expect(removeMobileFileDoc(new Map([['closed', { payload: 'large' }]]), 'closed').size).toBe(0) + }) + + it('rejects a late read after its tab closes', () => { + const lifecycle = createMobileFileDocLifecycle() + const tab = fileTab('file') + reconcileMobileFileDocTabs(lifecycle, [tab]) + const token = beginMobileFileDocLoad(lifecycle, tab) + expect(token).not.toBeNull() + + forgetMobileFileDocTab(lifecycle, tab.id) + + expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false) + }) + + it('keeps route state empty when a read resolves after close', async () => { + const lifecycle = new MobileSessionFileDocLifecycle() + const tab = fileTab('file') + let docs = new Map() + const updateDocs = (update: (current: typeof docs) => typeof docs) => { + docs = update(docs) + } + lifecycle.reconcile([{ ...tab, type: 'file' }], updateDocs) + let resolveRead: (doc: MobileFileTabDoc) => void = () => undefined + const read = new Promise((resolve) => { + resolveRead = resolve + }) + + const pending = lifecycle.load(tab, updateDocs, () => read) + expect(docs.get(tab.id)).toEqual({ status: 'loading' }) + lifecycle.close(tab.id, updateDocs) + resolveRead({ status: 'ready', kind: 'file', content: 'late', truncated: false, byteLength: 4 }) + await pending + + expect(docs.size).toBe(0) + }) + + it('rejects a late read when the same tab id is replaced with another file', () => { + const lifecycle = createMobileFileDocLifecycle() + const oldTab = fileTab('file', 'old.ts') + reconcileMobileFileDocTabs(lifecycle, [oldTab]) + const token = beginMobileFileDocLoad(lifecycle, oldTab) + expect(token).not.toBeNull() + + const replaced = reconcileMobileFileDocTabs(lifecycle, [fileTab('file', 'replacement.ts')]) + + expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false) + expect( + retainLiveMobileFileDocs( + new Map([['file', { payload: 'old contents' }]]), + lifecycle, + replaced + ) + ).toEqual(new Map()) + }) + + it('lets only the newest read for a live tab commit', () => { + const lifecycle = createMobileFileDocLifecycle() + const tab = fileTab('file') + reconcileMobileFileDocTabs(lifecycle, [tab]) + const first = beginMobileFileDocLoad(lifecycle, tab) + const second = beginMobileFileDocLoad(lifecycle, tab) + + expect(finishMobileFileDocLoad(lifecycle, first!)).toBe(false) + expect(finishMobileFileDocLoad(lifecycle, second!)).toBe(true) + }) + + it('invalidates requests when the route scope resets even if tab ids are reused', () => { + const lifecycle = createMobileFileDocLifecycle() + const tab = fileTab('reused') + reconcileMobileFileDocTabs(lifecycle, [tab]) + const oldScope = beginMobileFileDocLoad(lifecycle, tab) + + resetMobileFileDocLifecycle(lifecycle) + reconcileMobileFileDocTabs(lifecycle, [tab]) + const newScope = beginMobileFileDocLoad(lifecycle, tab) + + expect(finishMobileFileDocLoad(lifecycle, oldScope!)).toBe(false) + expect(finishMobileFileDocLoad(lifecycle, newScope!)).toBe(true) + }) +}) diff --git a/mobile/src/session/mobile-session-file-doc-lifecycle.ts b/mobile/src/session/mobile-session-file-doc-lifecycle.ts new file mode 100644 index 00000000000..843aa21758e --- /dev/null +++ b/mobile/src/session/mobile-session-file-doc-lifecycle.ts @@ -0,0 +1,242 @@ +import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc' + +export type MobileFileTabIdentity = { + id: string + filePath: string + relativePath: string + mode?: 'edit' | 'diff' + diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit' +} + +export type MobileFileDocLoadToken = { + generation: number + requestId: number + tabId: string + tabIdentity: string +} + +export type MobileFileDocLifecycle = { + generation: number + nextRequestId: number + liveTabIdentityById: Map + activeRequestIdByTabId: Map +} + +type MobileFileTabCandidate = { + type: string + id: string + filePath?: unknown + relativePath?: unknown + mode?: unknown + diffSource?: unknown +} + +type MobileSessionFileDoc = + | MobileFileTabDoc + | { status: 'loading' } + | { status: 'error'; message: string } + +type MobileSessionFileDocUpdater = ( + update: (current: Map) => Map +) => void + +export class MobileSessionFileDocLifecycle { + private readonly lifecycle = createMobileFileDocLifecycle() + + reconcile( + tabs: readonly MobileFileTabCandidate[], + updateDocs: MobileSessionFileDocUpdater + ): void { + const replacedTabIds = reconcileMobileFileDocTabs( + this.lifecycle, + tabs.filter(isMobileFileTabIdentity) + ) + updateDocs((current) => retainLiveMobileFileDocs(current, this.lifecycle, replacedTabIds)) + } + + async load( + tab: MobileFileTabIdentity, + updateDocs: MobileSessionFileDocUpdater, + read: () => Promise + ): Promise { + const token = beginMobileFileDocLoad(this.lifecycle, tab) + if (!token) { + return + } + updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' })) + try { + const doc = await read() + if (finishMobileFileDocLoad(this.lifecycle, token)) { + updateDocs((current) => new Map(current).set(tab.id, doc)) + } + } catch (error) { + if (finishMobileFileDocLoad(this.lifecycle, token)) { + updateDocs((current) => + new Map(current).set(tab.id, { + status: 'error', + message: getMobileFileDocLoadErrorMessage(tab, error) + }) + ) + } + } + } + + close(tabId: string, updateDocs: MobileSessionFileDocUpdater): void { + forgetMobileFileDocTab(this.lifecycle, tabId) + updateDocs((current) => removeMobileFileDoc(current, tabId)) + } + + reset(): void { + resetMobileFileDocLifecycle(this.lifecycle) + } +} + +export function createMobileFileDocLifecycle(): MobileFileDocLifecycle { + return { + generation: 0, + nextRequestId: 0, + liveTabIdentityById: new Map(), + activeRequestIdByTabId: new Map() + } +} + +export function reconcileMobileFileDocTabs( + lifecycle: MobileFileDocLifecycle, + tabs: readonly MobileFileTabIdentity[] +): ReadonlySet { + const nextIdentityById = new Map(tabs.map((tab) => [tab.id, getMobileFileTabIdentity(tab)])) + const replacedTabIds = new Set() + for (const tabId of lifecycle.activeRequestIdByTabId.keys()) { + const nextIdentity = nextIdentityById.get(tabId) + const currentIdentity = lifecycle.liveTabIdentityById.get(tabId) + if (!nextIdentity || nextIdentity !== currentIdentity) { + lifecycle.activeRequestIdByTabId.delete(tabId) + } + } + for (const [tabId, currentIdentity] of lifecycle.liveTabIdentityById) { + const nextIdentity = nextIdentityById.get(tabId) + if (nextIdentity && nextIdentity !== currentIdentity) { + replacedTabIds.add(tabId) + } + } + lifecycle.liveTabIdentityById.clear() + for (const [tabId, identity] of nextIdentityById) { + lifecycle.liveTabIdentityById.set(tabId, identity) + } + return replacedTabIds +} + +export function beginMobileFileDocLoad( + lifecycle: MobileFileDocLifecycle, + tab: MobileFileTabIdentity +): MobileFileDocLoadToken | null { + const tabIdentity = getMobileFileTabIdentity(tab) + if (lifecycle.liveTabIdentityById.get(tab.id) !== tabIdentity) { + return null + } + const requestId = ++lifecycle.nextRequestId + lifecycle.activeRequestIdByTabId.set(tab.id, requestId) + return { + generation: lifecycle.generation, + requestId, + tabId: tab.id, + tabIdentity + } +} + +export function finishMobileFileDocLoad( + lifecycle: MobileFileDocLifecycle, + token: MobileFileDocLoadToken +): boolean { + if (!isCurrentMobileFileDocLoad(lifecycle, token)) { + return false + } + lifecycle.activeRequestIdByTabId.delete(token.tabId) + return true +} + +export function forgetMobileFileDocTab(lifecycle: MobileFileDocLifecycle, tabId: string): void { + lifecycle.liveTabIdentityById.delete(tabId) + lifecycle.activeRequestIdByTabId.delete(tabId) +} + +export function resetMobileFileDocLifecycle(lifecycle: MobileFileDocLifecycle): void { + lifecycle.generation += 1 + lifecycle.liveTabIdentityById.clear() + lifecycle.activeRequestIdByTabId.clear() +} + +export function retainLiveMobileFileDocs( + docs: Map, + lifecycle: MobileFileDocLifecycle, + replacedTabIds?: ReadonlySet +): Map { + let next: Map | null = null + for (const tabId of docs.keys()) { + if (lifecycle.liveTabIdentityById.has(tabId) && !replacedTabIds?.has(tabId)) { + continue + } + next ??= new Map(docs) + next.delete(tabId) + } + return next ?? docs +} + +export function removeMobileFileDoc(docs: Map, tabId: string): Map { + if (!docs.has(tabId)) { + return docs + } + const next = new Map(docs) + next.delete(tabId) + return next +} + +function isCurrentMobileFileDocLoad( + lifecycle: MobileFileDocLifecycle, + token: MobileFileDocLoadToken +): boolean { + return ( + lifecycle.generation === token.generation && + lifecycle.liveTabIdentityById.get(token.tabId) === token.tabIdentity && + lifecycle.activeRequestIdByTabId.get(token.tabId) === token.requestId + ) +} + +function getMobileFileTabIdentity(tab: MobileFileTabIdentity): string { + return JSON.stringify([ + tab.id, + tab.filePath, + tab.relativePath, + tab.mode ?? '', + tab.diffSource ?? '' + ]) +} + +function isMobileFileTabIdentity( + tab: MobileFileTabCandidate +): tab is MobileFileTabCandidate & MobileFileTabIdentity { + return ( + tab.type === 'file' && + typeof tab.filePath === 'string' && + typeof tab.relativePath === 'string' && + (tab.mode === undefined || tab.mode === 'edit' || tab.mode === 'diff') && + (tab.diffSource === undefined || + tab.diffSource === 'staged' || + tab.diffSource === 'unstaged' || + tab.diffSource === 'branch' || + tab.diffSource === 'commit') + ) +} + +function getMobileFileDocLoadErrorMessage(tab: MobileFileTabIdentity, error: unknown): string { + const message = error instanceof Error ? error.message : '' + if (message === 'binary_file') { + return 'Binary preview unavailable' + } + if (message === 'file_too_large') { + return 'File too large for mobile preview' + } + return tab.diffSource === 'staged' || tab.diffSource === 'unstaged' + ? "Couldn't load diff preview" + : "Couldn't load file preview" +} diff --git a/mobile/src/session/mobile-session-markdown-doc-lifecycle.test.ts b/mobile/src/session/mobile-session-markdown-doc-lifecycle.test.ts new file mode 100644 index 00000000000..ce566029f3e --- /dev/null +++ b/mobile/src/session/mobile-session-markdown-doc-lifecycle.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import type { + MarkdownDocState, + MobileSessionTab +} from '../../app/h/[hostId]/session/mobile-session-route-types' +import { MobileSessionMarkdownDocLifecycle } from './mobile-session-markdown-doc-lifecycle' + +type MarkdownTab = Extract +type ReadyMarkdownDoc = Extract + +function markdownTab(id: string, relativePath = `${id}.md`): MarkdownTab { + return { + type: 'markdown', + id, + title: relativePath, + filePath: `/repo/${relativePath}`, + relativePath, + isDirty: false, + isActive: false, + documentVersion: 'v1' + } +} + +function readyDoc(content: string, isDirty = false): ReadyMarkdownDoc { + return { + status: 'ready', + content, + localContent: content, + baseVersion: 'v1', + isDirty, + editable: true + } +} + +describe('mobile session markdown document lifecycle', () => { + it('retains payloads only for markdown tabs in the accepted snapshot', () => { + const lifecycle = new MobileSessionMarkdownDocLifecycle() + let docs = new Map([ + ['closed', readyDoc('x'.repeat(1_000_000))], + ['live', readyDoc('live')] + ]) + + lifecycle.reconcile([markdownTab('live')], (update) => { + docs = update(docs) + }) + + expect([...docs.keys()]).toEqual(['live']) + }) + + it('retains a dirty orphan when reconciliation keeps its draft tab', () => { + const lifecycle = new MobileSessionMarkdownDocLifecycle() + let docs = new Map([['draft', readyDoc('unsaved', true)]]) + + lifecycle.reconcile([markdownTab('draft')], (update) => { + docs = update(docs) + }) + + expect(docs.get('draft')).toEqual(readyDoc('unsaved', true)) + }) + + it('does not let a late read resurrect a closed tab', async () => { + const lifecycle = new MobileSessionMarkdownDocLifecycle() + const tab = markdownTab('late') + let docs = new Map() + const updateDocs = (update: (current: typeof docs) => typeof docs) => { + docs = update(docs) + } + lifecycle.reconcile([tab], updateDocs) + let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined + const read = new Promise((resolve) => { + resolveRead = resolve + }) + + const pending = lifecycle.load(tab, updateDocs, () => read) + lifecycle.close(tab.id, updateDocs) + resolveRead(readyDoc('late payload')) + await pending + + expect(docs.size).toBe(0) + }) + + it('does not let an old path overwrite a replacement using the same tab id', async () => { + const lifecycle = new MobileSessionMarkdownDocLifecycle() + const oldTab = markdownTab('same', 'old.md') + let docs = new Map() + const updateDocs = (update: (current: typeof docs) => typeof docs) => { + docs = update(docs) + } + lifecycle.reconcile([oldTab], updateDocs) + let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined + const pending = lifecycle.load( + oldTab, + updateDocs, + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + + lifecycle.reconcile([markdownTab('same', 'new.md')], updateDocs) + resolveRead(readyDoc('old payload')) + await pending + + expect(docs.size).toBe(0) + }) +}) diff --git a/mobile/src/session/mobile-session-markdown-doc-lifecycle.ts b/mobile/src/session/mobile-session-markdown-doc-lifecycle.ts new file mode 100644 index 00000000000..29554ce3c69 --- /dev/null +++ b/mobile/src/session/mobile-session-markdown-doc-lifecycle.ts @@ -0,0 +1,147 @@ +import type { + MarkdownDocState, + MobileSessionTab +} from '../../app/h/[hostId]/session/mobile-session-route-types' + +type MobileMarkdownTab = Extract + +type MobileMarkdownDocUpdater = ( + update: (current: Map) => Map +) => void + +type MobileMarkdownDocLoadToken = { + generation: number + requestId: number + tabId: string + tabIdentity: string +} + +export class MobileSessionMarkdownDocLifecycle { + private generation = 0 + private nextRequestId = 0 + private readonly liveTabIdentityById = new Map() + private readonly activeRequestIdByTabId = new Map() + + reconcile(tabs: readonly MobileSessionTab[], updateDocs: MobileMarkdownDocUpdater): void { + const nextIdentityById = new Map( + tabs + .filter((tab): tab is MobileMarkdownTab => tab.type === 'markdown') + .map((tab) => [tab.id, markdownTabIdentity(tab)]) + ) + const replacedTabIds = new Set() + for (const [tabId, currentIdentity] of this.liveTabIdentityById) { + const nextIdentity = nextIdentityById.get(tabId) + if (nextIdentity && nextIdentity !== currentIdentity) { + replacedTabIds.add(tabId) + } + } + for (const tabId of this.activeRequestIdByTabId.keys()) { + if (nextIdentityById.get(tabId) !== this.liveTabIdentityById.get(tabId)) { + this.activeRequestIdByTabId.delete(tabId) + } + } + this.liveTabIdentityById.clear() + for (const [tabId, identity] of nextIdentityById) { + this.liveTabIdentityById.set(tabId, identity) + } + updateDocs((current) => retainLiveMarkdownDocs(current, nextIdentityById, replacedTabIds)) + } + + async load( + tab: MobileMarkdownTab, + updateDocs: MobileMarkdownDocUpdater, + read: () => Promise> + ): Promise { + const token = this.beginLoad(tab) + if (!token) { + return + } + updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' })) + try { + const doc = await read() + if (this.finishLoad(token)) { + updateDocs((current) => new Map(current).set(tab.id, doc)) + } + } catch { + if (this.finishLoad(token)) { + updateDocs((current) => + new Map(current).set(tab.id, { + status: 'error', + message: "Couldn't load markdown" + }) + ) + } + } + } + + close(tabId: string, updateDocs: MobileMarkdownDocUpdater): void { + this.liveTabIdentityById.delete(tabId) + this.activeRequestIdByTabId.delete(tabId) + updateDocs((current) => removeMarkdownDoc(current, tabId)) + } + + reset(): void { + this.generation += 1 + this.liveTabIdentityById.clear() + this.activeRequestIdByTabId.clear() + } + + private beginLoad(tab: MobileMarkdownTab): MobileMarkdownDocLoadToken | null { + const tabIdentity = markdownTabIdentity(tab) + if (this.liveTabIdentityById.get(tab.id) !== tabIdentity) { + return null + } + const requestId = ++this.nextRequestId + this.activeRequestIdByTabId.set(tab.id, requestId) + return { + generation: this.generation, + requestId, + tabId: tab.id, + tabIdentity + } + } + + private finishLoad(token: MobileMarkdownDocLoadToken): boolean { + if ( + token.generation !== this.generation || + this.liveTabIdentityById.get(token.tabId) !== token.tabIdentity || + this.activeRequestIdByTabId.get(token.tabId) !== token.requestId + ) { + return false + } + this.activeRequestIdByTabId.delete(token.tabId) + return true + } +} + +function retainLiveMarkdownDocs( + docs: Map, + liveIdentityById: ReadonlyMap, + replacedTabIds: ReadonlySet +): Map { + let next: Map | null = null + for (const tabId of docs.keys()) { + if (liveIdentityById.has(tabId) && !replacedTabIds.has(tabId)) { + continue + } + next ??= new Map(docs) + next.delete(tabId) + } + return next ?? docs +} + +function removeMarkdownDoc( + docs: Map, + tabId: string +): Map { + if (!docs.has(tabId)) { + return docs + } + const next = new Map(docs) + next.delete(tabId) + return next +} + +function markdownTabIdentity(tab: MobileMarkdownTab): string { + return JSON.stringify([tab.id, tab.filePath, tab.relativePath]) +} diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index 07b5cf1737c..a86fe393e29 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -5,10 +5,6 @@ const source = readFileSync( new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), 'utf8' ) -const reconciliationHookSource = readFileSync( - new URL('./use-mobile-session-tabs-reconciliation.ts', import.meta.url), - 'utf8' -) function sliceBetween(startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) @@ -33,23 +29,24 @@ describe('mobile session startup', () => { expect(autoCreateEffect).toContain('void handleCreateTerminal()') }) - it('delegates stream ownership while retaining the exact terminal polling cadence', () => { - expect(source).toContain('useMobileSessionTabsReconciliation<') - expect(source).toContain('const applicationRevision = ++appliedSessionTabsRevisionRef.current') - expect(source).toContain('getApplicationRevision: getSessionTabsApplicationRevision') - expect(source).not.toContain("client.subscribe(\n 'session.tabs.subscribe'") - expect(reconciliationHookSource).toContain("client.subscribe(\n 'session.tabs.subscribe'") - expect(reconciliationHookSource).toContain( - "if (AppState.currentState !== 'active') {\n controller.setReconciliationActive(false)" + it('stops fallback list polling in the background and reconciles on resume', () => { + const pollEffect = sliceBetween( + "const refreshOnForeground = () => {\n if (AppState.currentState !== 'active')", + '// Why: pick up Settings → Terminal text size on return' ) - expect(reconciliationHookSource).toContain('void controller.poll()') - expect(reconciliationHookSource).toContain('void fetchTerminals()') - expect(reconciliationHookSource).toContain("AppState.addEventListener('change'") - expect(reconciliationHookSource).toContain('const interval = setInterval(') - expect(reconciliationHookSource).toContain('2000') - expect(reconciliationHookSource).toContain('controller.setReconciliationActive(false)') - expect(reconciliationHookSource).toContain('clearInterval(interval)') - expect(reconciliationHookSource).toContain('appStateSubscription.remove()') + + expect(pollEffect).toContain( + "if (AppState.currentState !== 'active') {\n return\n }\n void fetchSessionTabs()" + ) + expect(pollEffect).toContain('void fetchTerminals()') + expect(pollEffect).toContain("AppState.addEventListener('change'") + expect(pollEffect).toContain("if (state === 'active') {\n refreshOnForeground()") + expect(pollEffect).toContain('const interval = setInterval(refreshOnForeground, 2000)') + expect(pollEffect.lastIndexOf('\n refreshOnForeground()')).toBeGreaterThan( + pollEffect.indexOf("AppState.addEventListener('change'") + ) + expect(pollEffect).toContain('clearInterval(interval)') + expect(pollEffect).toContain('appStateSubscription.remove()') }) it('loads session tabs without waiting for desktop activation', () => { @@ -65,7 +62,7 @@ describe('mobile session startup', () => { expect(startupEffect).toContain("navigation: 'caller'") expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'") expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan( - startupEffect.indexOf('await ensureSessionTabs()') + startupEffect.indexOf('await fetchSessionTabs()') ) expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(response.result)') expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'") diff --git a/mobile/src/session/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/use-mobile-native-chat-file-search.test.ts index e0500019844..bbd4e8bb4f7 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.test.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts @@ -2,7 +2,10 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' +import { + retainMobileNativeChatFilePaths, + useMobileNativeChatFileSearch +} from './use-mobile-native-chat-file-search' type SearchState = ReturnType @@ -163,3 +166,15 @@ describe('useMobileNativeChatFileSearch', () => { expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) }) }) + +describe('retainMobileNativeChatFilePaths', () => { + it('accepts exact count and retained-byte limits and truncates at one over', () => { + const result = { + files: [{ relativePath: 'a' }, { relativePath: 'bb' }, { relativePath: 'ignored' }] + } + const exactBytes = 'a'.length * 2 + 64 + ('bb'.length * 2 + 64) + + expect(retainMobileNativeChatFilePaths(result, 2, exactBytes)).toEqual(['a', 'bb']) + expect(retainMobileNativeChatFilePaths(result, 3, exactBytes - 1)).toEqual(['a']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts index 53df98e3ee0..53296947f32 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -5,12 +5,31 @@ import { rankSuggestions } from './mobile-native-chat-autocomplete' const FILE_SEARCH_DEBOUNCE_MS = 120 const FILE_SEARCH_RESULT_LIMIT = 16 const FILE_SEARCH_QUERY_CACHE_LIMIT = 20 +export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS = 50_000 +export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES = 8 * 1024 * 1024 +const FILE_SEARCH_RESULT_MAX_RETAINED_BYTES = 256 * 1024 -function extractPaths(result: unknown): string[] { +export function retainMobileNativeChatFilePaths( + result: unknown, + maxPaths: number, + maxRetainedBytes: number +): string[] { const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? [] - return files - .map((file) => file.relativePath ?? '') - .filter((path): path is string => path.length > 0) + const paths: string[] = [] + let retainedBytes = 0 + for (const file of files) { + const path = file.relativePath + if (typeof path !== 'string' || path.length === 0) { + continue + } + const nextBytes = retainedBytes + path.length * 2 + 64 + if (paths.length >= maxPaths || nextBytes > maxRetainedBytes) { + break + } + paths.push(path) + retainedBytes = nextBytes + } + return paths } /** Debounces current-host path searches, bounds the mobile result/cache, and @@ -94,7 +113,11 @@ export function useMobileNativeChatFileSearch(args: { if (!response.ok || generationRef.current !== generation) { return null } - const paths = extractPaths(response.result) + const paths = retainMobileNativeChatFilePaths( + response.result, + MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS, + MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES + ) legacyPathsRef.current = paths return paths }) @@ -129,7 +152,13 @@ export function useMobileNativeChatFileSearch(args: { }) if (response.ok) { searchSupportedRef.current = true - applyPaths(extractPaths(response.result)) + applyPaths( + retainMobileNativeChatFilePaths( + response.result, + FILE_SEARCH_RESULT_LIMIT, + FILE_SEARCH_RESULT_MAX_RETAINED_BYTES + ) + ) return } if (response.error.code === 'method_not_found') { diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 8079a48d2b1..6018f353854 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -3,7 +3,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' -import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' // Fully stub the picker so the real expo/react-native chain never loads under @@ -67,7 +66,7 @@ function baseArgs(overrides: Partial & Pick): Hook scopeKey: SCOPE_A, enabled: true, showToast: vi.fn(), - baseSend: vi.fn().mockResolvedValue('accepted'), + baseSend: vi.fn().mockResolvedValue(true), sleep: async () => {}, ...overrides } @@ -85,9 +84,6 @@ describe('useMobileNativeChatImageAttachments', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true pick.mockReset() - // Stale markers live at module scope now (they outlive the screen), so they - // also outlive a test. - resetMobileNativeChatStaleInputForTests() }) afterEach(() => { act(() => renderer?.unmount()) @@ -153,7 +149,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) const baseSend = vi.fn(async (t: string) => { order.push(`text:${t}`) - return 'accepted' as const + return true }) // Record each terminal write so the paste-before-settle order is asserted, // not just implied by the call counts. @@ -208,7 +204,7 @@ describe('useMobileNativeChatImageAttachments', () => { sendResult(true), // Ctrl+U clear sendResult(true) // image paste ]) - const baseSend = vi.fn().mockResolvedValue('accepted') + const baseSend = vi.fn().mockResolvedValue(true) mount(baseArgs({ client: client as unknown as RpcClient, baseSend })) await act(async () => { @@ -231,7 +227,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('delegates straight to baseSend when there are no attachments', async () => { const client = makeClient([]) - const baseSend = vi.fn().mockResolvedValue('accepted') + const baseSend = vi.fn().mockResolvedValue(true) mount(baseArgs({ client: client as unknown as RpcClient, baseSend })) await act(async () => { @@ -249,7 +245,7 @@ describe('useMobileNativeChatImageAttachments', () => { sendResult(true), // Ctrl+U clear sendResult(false) // image paste rejected ]) - const baseSend = vi.fn().mockResolvedValue('accepted') + const baseSend = vi.fn().mockResolvedValue(true) const showToast = vi.fn() mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast })) await act(async () => { @@ -269,7 +265,7 @@ describe('useMobileNativeChatImageAttachments', () => { pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) // No terminal.send responses queued: the clear write throws (dropped transport). const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) - const baseSend = vi.fn().mockResolvedValue('accepted') + const baseSend = vi.fn().mockResolvedValue(true) const showToast = vi.fn() mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast })) await act(async () => { @@ -288,7 +284,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('surfaces a toast instead of a silent no-op when the input lease gate is closed', async () => { pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) - const baseSend = vi.fn().mockResolvedValue('accepted') + const baseSend = vi.fn().mockResolvedValue(true) const showToast = vi.fn() // Attaching is allowed without the lease; only the send is gated on it. mount(baseArgs({ client: client as unknown as RpcClient, enabled: false, baseSend, showToast })) @@ -308,7 +304,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('scopes chips to the tab that attached them', async () => { pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) - const baseSend = vi.fn().mockResolvedValue('accepted') + const baseSend = vi.fn().mockResolvedValue(true) const args = baseArgs({ client: client as unknown as RpcClient, baseSend }) mount(args) await act(async () => { @@ -382,7 +378,7 @@ describe('useMobileNativeChatImageAttachments', () => { methodNotFound('start'), ok('save', '/tmp/b.png') // second attach, while the send is parked on settle ]) - const baseSend = vi.fn().mockResolvedValue('accepted') + const baseSend = vi.fn().mockResolvedValue(true) let releaseSettle: (() => void) | null = null const args = baseArgs({ client: client as unknown as RpcClient, @@ -437,7 +433,7 @@ describe('useMobileNativeChatImageAttachments', () => { sendResult(true), // Ctrl+U clear sendResult(true) // image paste — into term-1 ]) - const baseSend = vi.fn().mockResolvedValue('accepted') + const baseSend = vi.fn().mockResolvedValue(true) const showToast = vi.fn() const activeHandleRef = { current: 'term-1' } let releaseSettle: (() => void) | null = null @@ -487,7 +483,7 @@ describe('useMobileNativeChatImageAttachments', () => { sendResult(false), // image paste rejected — stale input left in term-1 sendResult(true) // healing Ctrl+U before the text-only send ]) - const baseSend = vi.fn().mockResolvedValue('accepted') + const baseSend = vi.fn().mockResolvedValue(true) mount(baseArgs({ client: client as unknown as RpcClient, baseSend })) await act(async () => { await hook!.attachImage('library') @@ -514,98 +510,6 @@ describe('useMobileNativeChatImageAttachments', () => { expect(baseSend).toHaveBeenCalledWith('hi again') }) - it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) - const client = makeClient([ - methodNotFound('start'), - ok('save', '/tmp/a.png'), - sendResult(true), // Ctrl+U clear - sendResult(true), // image paste accepted — path now sits on term-1's input - sendResult(true) // healing Ctrl+U before the follow-up text send - ]) - const baseSend = vi.fn().mockResolvedValueOnce('unknown').mockResolvedValueOnce('accepted') - mount(baseArgs({ client: client as unknown as RpcClient, baseSend })) - await act(async () => { - await hook!.attachImage('library') - }) - - // Ambiguous delivery: the paste landed but the text+Enter may not have. - let accepted = false - await act(async () => { - accepted = await hook!.sendNativeChat('pic') - }) - // Mirrors the text path: 'unknown' usually WAS delivered, so the send is not - // surfaced as a failure and the chip does not linger for a double-send retry. - expect(accepted).toBe(true) - expect(hook!.attachments).toEqual([]) - - // The next plain-text send must Ctrl+U first — if the Enter was lost, the - // orphaned image path would otherwise glue onto this later message. - await act(async () => { - accepted = await hook!.sendNativeChat('later message') - }) - expect(accepted).toBe(true) - const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') - expect(sendCalls).toHaveLength(3) - expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false }) - expect(baseSend).toHaveBeenNthCalledWith(1, 'pic', ['file:///a.jpg']) - expect(baseSend).toHaveBeenNthCalledWith(2, 'later message') - }) - - it('still heals after the session screen unmounts and remounts (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) - const client = makeClient([ - methodNotFound('start'), - ok('save', '/tmp/a.png'), - sendResult(true), // Ctrl+U clear - sendResult(true), // image paste accepted — path now sits on term-1's input - sendResult(true) // healing Ctrl+U on the remounted screen - ]) - const baseSend = vi.fn().mockResolvedValueOnce('unknown').mockResolvedValueOnce('accepted') - mount(baseArgs({ client: client as unknown as RpcClient, baseSend })) - await act(async () => { - await hook!.attachImage('library') - }) - await act(async () => { - await hook!.sendNativeChat('pic') - }) - - // Back out of the session screen and return. The orphaned paste sits on the - // HOST's input line, so a fresh hook must still know to clear it. - act(() => renderer!.unmount()) - renderer = null - mount(baseArgs({ client: client as unknown as RpcClient, baseSend })) - expect(hook!.attachments).toEqual([]) - - let accepted = false - await act(async () => { - accepted = await hook!.sendNativeChat('later message') - }) - expect(accepted).toBe(true) - const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') - expect(sendCalls).toHaveLength(3) - expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false }) - expect(baseSend).toHaveBeenNthCalledWith(2, 'later message') - }) - - it('does not heal after an unknown text-only send (nothing was pasted first)', async () => { - const client = makeClient([]) - const baseSend = vi.fn().mockResolvedValueOnce('unknown').mockResolvedValueOnce('accepted') - mount(baseArgs({ client: client as unknown as RpcClient, baseSend })) - - let accepted = false - await act(async () => { - accepted = await hook!.sendNativeChat('first') - }) - expect(accepted).toBe(true) - await act(async () => { - accepted = await hook!.sendNativeChat('second') - }) - expect(accepted).toBe(true) - // No paste preceded the ambiguous send, so no healing Ctrl+U hits the wire. - expect(client.calls).toHaveLength(0) - }) - it('retains the stale marker when a rejected healing clear blocks text-only send', async () => { pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) const client = makeClient([ @@ -616,7 +520,7 @@ describe('useMobileNativeChatImageAttachments', () => { sendResult(false), // first healing Ctrl+U rejected sendResult(true) // retry healing Ctrl+U accepted ]) - const baseSend = vi.fn().mockResolvedValueOnce('rejected').mockResolvedValueOnce('accepted') + const baseSend = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) mount(baseArgs({ client: client as unknown as RpcClient, baseSend })) await act(async () => { await hook!.attachImage('library') @@ -661,7 +565,7 @@ describe('useMobileNativeChatImageAttachments', () => { sendResult(true), deferredClear ]) - const baseSend = vi.fn().mockResolvedValueOnce('rejected') + const baseSend = vi.fn().mockResolvedValueOnce(false) const activeHandleRef = { current: 'term-1' } mount(baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend })) await act(async () => { @@ -710,10 +614,10 @@ describe('useMobileNativeChatImageAttachments', () => { ]) const baseSend = vi .fn() - .mockResolvedValueOnce('rejected') - .mockResolvedValueOnce('rejected') - .mockResolvedValueOnce('accepted') - .mockResolvedValueOnce('accepted') + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) const activeHandleRef = { current: 'term-1' } const args = baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend }) mount(args) diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index f0ce66dd92b..4d8ea9d8eb6 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -15,13 +15,6 @@ import { MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS, pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' -import { - clearMobileNativeChatInputStale, - healMobileNativeChatStaleInput, - isMobileNativeChatInputStale, - markMobileNativeChatInputStale -} from './mobile-native-chat-stale-input' type CurrentRef = { readonly current: T } type ShowToast = (message: string, durationMs?: number) => void @@ -39,14 +32,9 @@ type Args = { /** The native-chat input lease is ready — same gate `handleNativeChatSend` uses. */ readonly enabled: boolean readonly showToast: ShowToast - /** The plain text send (controller.handleNativeChatSendWithOutcome); wrapped so - * images ride along. The optional URIs drive the optimistic echo's thumbnails. - * Must preserve 'unknown': after a successful paste, an ambiguously-delivered - * text+Enter may have left the image on the input line, which needs healing. */ - readonly baseSend: ( - text: string, - imagePreviewUris?: string[] - ) => Promise + /** The plain text send (controller.handleNativeChatSend); wrapped so images ride + * along. The optional URIs drive the optimistic echo's thumbnails. */ + readonly baseSend: (text: string, imagePreviewUris?: string[]) => Promise readonly onAttachSuccess?: () => void readonly onError?: () => void // Injected so the settle between image paste and submit is instant in tests. @@ -83,6 +71,10 @@ function withScopeAttachments( return remaining } +function markTerminalInputStale(staleInputs: Set, terminal: string): void { + staleInputs.add(terminal) +} + const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) @@ -111,6 +103,8 @@ export function useMobileNativeChatImageAttachments({ // checked 'connected' at entry, so only a ref can see a mid-upload disconnect. const connStateRef = useRef(connState) connStateRef.current = connState + // Terminals whose input may hold a failed paste; each must heal independently. + const staleInputTerminalsRef = useRef(new Set()) // Serialize clear/paste/submit ownership per terminal while allowing other tabs to send. const sendInFlightTerminalsRef = useRef(new Set()) @@ -220,22 +214,31 @@ export function useMobileNativeChatImageAttachments({ // otherwise glue the stale image paste onto this message. Best-effort — // on failure the marker stays set and the text must not be submitted. const staleTerminal = activeHandleRef.current - if (staleTerminal && isMobileNativeChatInputStale(staleTerminal) && client) { - const healed = await healMobileNativeChatStaleInput({ - client, - terminal: staleTerminal, - deviceToken: deviceTokenRef.current - }) - // A tab switch during the clear would send this text to a terminal the - // clear never touched, so abort rather than reroute it. - if (!healed || activeHandleRef.current !== staleTerminal) { + if (staleTerminal && staleInputTerminalsRef.current.has(staleTerminal) && client) { + let cleared = false + try { + cleared = await pasteMobileNativeChatImagePaths({ + client, + terminal: staleTerminal, + deviceToken: deviceTokenRef.current, + imagePaths: [] + }) + } catch { + // Leave marked for the next attempt. + } + if (!cleared) { + onError?.() + showToast('Message not sent', 1500) + return false + } + staleInputTerminalsRef.current.delete(staleTerminal) + if (activeHandleRef.current !== staleTerminal) { onError?.() showToast('Message not sent', 1500) return false } } - // Text-only sends paste nothing first, so 'unknown' leaves no stale input. - return (await baseSend(text)) !== 'rejected' + return baseSend(text) } const handle = activeHandleRef.current if (!client || !handle || !enabled || connState !== 'connected') { @@ -253,13 +256,13 @@ export function useMobileNativeChatImageAttachments({ }) if (!pasted) { // Keep the chips so the user can retry; the failed paste never submitted. - markMobileNativeChatInputStale(handle) + markTerminalInputStale(staleInputTerminalsRef.current, handle) onError?.() showToast('Message not sent', 1500) return false } // The paste's leading Ctrl+U cleared any earlier stale input in `handle`. - clearMobileNativeChatInputStale(handle) + staleInputTerminalsRef.current.delete(handle) // Let the TUI absorb the image paste before the text + Enter follow. The // preview URIs ride along to baseSend so the sent bubble shows the photo // immediately (empty text still submits a bare Enter through baseSend). @@ -268,25 +271,22 @@ export function useMobileNativeChatImageAttachments({ // route the text + Enter to a different terminal than the images. Abort — // the chips keep their scope and a retry's Ctrl+U clears the stale paste. if (activeHandleRef.current !== handle) { - markMobileNativeChatInputStale(handle) + markTerminalInputStale(staleInputTerminalsRef.current, handle) onError?.() showToast('Message not sent', 1500) return false } - const outcome = await baseSend( + const accepted = await baseSend( text, pendingImages.map((attachment) => attachment.previewUri) ) - if (outcome !== 'accepted') { - // 'rejected' leaves the pasted image path on this input line; 'unknown' - // may have lost the text+Enter AFTER the paste landed, orphaning the - // image onto whatever is sent next (#10228) — both must heal first. - markMobileNativeChatInputStale(handle) + if (!accepted) { + // A rejected submit leaves the successfully pasted image path on this input line. + markTerminalInputStale(staleInputTerminalsRef.current, handle) } - if (outcome !== 'rejected') { + if (accepted) { // Drop only what rode along — a chip attached while this send was in - // flight keeps waiting for its own send. 'unknown' clears too: the - // send usually DID land, and a kept chip would double-send the image. + // flight keeps waiting for its own send. const sentIds = new Set(pendingImages.map((attachment) => attachment.id)) setAttachmentsByScope((prev) => withScopeAttachments( @@ -296,12 +296,12 @@ export function useMobileNativeChatImageAttachments({ ) ) } - return outcome !== 'rejected' + return accepted } catch { // A thrown paste/send (network/RPC) keeps the chips and honors the // Promise contract instead of rejecting. Retry-safe: the next // attempt's leading Ctrl+U clears whatever fraction of the paste landed. - markMobileNativeChatInputStale(handle) + markTerminalInputStale(staleInputTerminalsRef.current, handle) onError?.() showToast('Message not sent', 1500) return false diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index 7428bde224d..47698b031e2 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -1,7 +1,6 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import type { MobileImageSource } from './mobile-image-source-picker' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { useMobileImageAttachment } from './use-mobile-image-attachment' import { useMobileNativeChatImageAttachments, @@ -23,12 +22,7 @@ type Args = { readonly nativeChatInputLeaseReady: boolean readonly getActiveWorktreeConnectionId: () => Promise readonly beforeTerminalSend: (terminal: string) => Promise - /** Outcome-preserving so an ambiguous ('unknown') delivery after an image - * paste can mark the terminal input for healing (#10228). */ - readonly nativeChatBaseSend: ( - text: string, - images?: string[] - ) => Promise + readonly nativeChatBaseSend: (text: string, images?: string[]) => Promise readonly showToast: (message: string, durationMs?: number) => void readonly onSuccess: () => void readonly onError: () => void diff --git a/mobile/src/tasks/mobile-pr-file-content-cache.test.ts b/mobile/src/tasks/mobile-pr-file-content-cache.test.ts new file mode 100644 index 00000000000..bb32226a98a --- /dev/null +++ b/mobile/src/tasks/mobile-pr-file-content-cache.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubPRFileContents } from '../../../src/shared/types' +import { + MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES, + MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES, + MobilePrFileContentCache, + createMobilePrFileContentKey, + createMobilePrFileContentScope, + getMobilePrFileContentByteCount, + getMobilePrFileContentsForScope +} from './mobile-pr-file-content-cache' +import { + createMobileItemPrFileContentScope, + createMobileProjectPrFileContentScope +} from './use-mobile-pr-file-content-cache' + +function contents(original: string, modified = ''): GitHubPRFileContents { + return { + original, + modified, + originalIsBinary: false, + modifiedIsBinary: false + } +} + +const scopeA = createMobilePrFileContentScope({ + source: 'item', + repoId: 'repo-1', + prNumber: 10, + headSha: 'head-a', + baseSha: 'base-a' +}) +const scopeB = createMobilePrFileContentScope({ + source: 'item', + repoId: 'repo-1', + prNumber: 11, + headSha: 'head-b', + baseSha: 'base-a' +}) + +describe('MobilePrFileContentCache', () => { + it('derives route scopes only from complete GitHub PR revisions', () => { + expect( + createMobileItemPrFileContentScope( + { provider: 'github', source: { type: 'pr', repoId: 'repo-1', number: 10 } }, + { provider: 'github', headSha: 'head-a', baseSha: 'base-a' } + ) + ).toBe(scopeA) + expect( + createMobileProjectPrFileContentScope( + { itemType: 'PULL_REQUEST', content: { number: 10 } }, + { id: 'repo-1' }, + { provider: 'github', headSha: 'head-a' } + ) + ).toBeNull() + expect( + createMobileProjectPrFileContentScope( + { itemType: 'PULL_REQUEST', content: { number: 10 } }, + { id: 'repo-1' }, + { provider: 'github', headSha: 'head-a', baseSha: 'base-a' }, + { host: 'github.example', owner: 'orca', repo: 'app' } + ) + ).toBe( + createMobilePrFileContentScope({ + source: 'project', + repoId: 'repo-1', + prNumber: 10, + headSha: 'head-a', + baseSha: 'base-a', + repository: { host: 'github.example', owner: 'orca', repo: 'app' } + }) + ) + }) + + it('uses the desktop diff budget as a hard byte cap with a smaller mobile entry cap', () => { + expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES).toBeLessThan(64) + expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES).toBe(24_000_000) + }) + + it('evicts least-recently-used files at the entry limit', () => { + const cache = new MobilePrFileContentCache(2, 100) + const a = createMobilePrFileContentKey({ path: 'a.ts' }) + const b = createMobilePrFileContentKey({ path: 'b.ts' }) + const c = createMobilePrFileContentKey({ path: 'c.ts' }) + cache.commitRequest(cache.beginRequest(scopeA, a), contents('a')) + cache.commitRequest(cache.beginRequest(scopeA, b), contents('b')) + expect(cache.select(scopeA, a).contents).toEqual(contents('a')) + + cache.commitRequest(cache.beginRequest(scopeA, c), contents('c')) + + expect(cache.evidence()).toMatchObject({ entryCount: 2, keysOldestFirst: [a, c] }) + expect(cache.select(scopeA, b).contents).toBeUndefined() + }) + + it('evicts older payloads to stay within the byte budget', () => { + const cache = new MobilePrFileContentCache(10, 8) + const a = createMobilePrFileContentKey({ path: 'a.ts' }) + const b = createMobilePrFileContentKey({ path: 'b.ts' }) + cache.commitRequest(cache.beginRequest(scopeA, a), contents('12345')) + cache.commitRequest(cache.beginRequest(scopeA, b), contents('67890')) + + expect(cache.evidence()).toMatchObject({ + entryCount: 1, + retainedBytes: 5, + keysOldestFirst: [b] + }) + }) + + it('measures retained UTF-8 bytes rather than JavaScript code units', () => { + expect(getMobilePrFileContentByteCount(contents('a😀', 'é'))).toBe(7) + }) + + it('counts the retained side when the other side is an oversized sentinel', () => { + expect( + getMobilePrFileContentByteCount({ + ...contents('', 'retained'), + originalTooLarge: true + }) + ).toBe(8) + }) + + it('rejects a response from a prior PR context after scope replacement', () => { + const cache = new MobilePrFileContentCache(2, 100) + const key = createMobilePrFileContentKey({ path: 'file.ts' }) + const oldRequest = cache.beginRequest(scopeA, key) + cache.activateScope(scopeB) + + expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale') + expect(cache.evidence()).toMatchObject({ scope: scopeB, entryCount: 0, retainedBytes: 0 }) + }) + + it('rejects an older request after the active file selection changes', () => { + const cache = new MobilePrFileContentCache(2, 100) + const a = createMobilePrFileContentKey({ path: 'a.ts' }) + const b = createMobilePrFileContentKey({ path: 'b.ts' }) + const oldRequest = cache.beginRequest(scopeA, a) + cache.select(scopeA, b) + const currentRequest = cache.beginRequest(scopeA, b) + + expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale') + expect(cache.commitRequest(currentRequest, contents('current'))).toBe('stored') + }) + + it('publishes only the active scope and refuses a single over-budget payload', () => { + const cache = new MobilePrFileContentCache(2, 4) + const key = createMobilePrFileContentKey({ path: 'file.ts' }) + expect(cache.commitRequest(cache.beginRequest(scopeA, key), contents('12345'))).toBe( + 'too-large' + ) + expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeA)).toEqual({}) + expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeB)).toEqual({}) + }) +}) diff --git a/mobile/src/tasks/mobile-pr-file-content-cache.ts b/mobile/src/tasks/mobile-pr-file-content-cache.ts new file mode 100644 index 00000000000..5dcb17a3912 --- /dev/null +++ b/mobile/src/tasks/mobile-pr-file-content-cache.ts @@ -0,0 +1,226 @@ +import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../../src/shared/large-diff-render-limit' +import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types' + +export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES = 8 +export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4 + +export type MobilePrFileContentScopeInput = { + source: 'item' | 'project' + repoId: string + prNumber: number + headSha: string + baseSha: string + repository?: GitHubRepositoryIdentity | null +} + +export type MobilePrFileContentKeyInput = { + path: string + oldPath?: string + status?: string +} + +export type MobilePrFileContentRequestToken = { + scope: string + key: string + requestId: number +} + +export type MobilePrFileContentCacheSnapshot = { + scope: string | null + contentsByKey: Readonly> +} + +export type MobilePrFileContentCacheEvidence = { + scope: string | null + entryCount: number + retainedBytes: number + keysOldestFirst: string[] +} + +type CacheEntry = { + contents: GitHubPRFileContents + byteCount: number +} + +type CacheSelection = { + contents: GitHubPRFileContents | undefined + scopeChanged: boolean +} + +export class MobilePrFileContentCache { + private readonly entries = new Map() + private scope: string | null = null + private retainedBytes = 0 + private requestSequence = 0 + private activeRequest: MobilePrFileContentRequestToken | null = null + + constructor( + private readonly maxEntries = MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES, + private readonly maxBytes = MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES + ) { + if ( + !Number.isInteger(maxEntries) || + maxEntries < 1 || + !Number.isFinite(maxBytes) || + maxBytes < 1 + ) { + throw new Error('Mobile PR file-content cache limits must be positive') + } + } + + activateScope(scope: string): boolean { + if (this.scope === scope) { + return false + } + this.scope = scope + this.entries.clear() + this.retainedBytes = 0 + this.activeRequest = null + return true + } + + clear(): void { + this.scope = null + this.entries.clear() + this.retainedBytes = 0 + this.activeRequest = null + } + + select(scope: string, key: string): CacheSelection { + const scopeChanged = this.activateScope(scope) + this.activeRequest = null + const entry = this.entries.get(key) + if (!entry) { + return { contents: undefined, scopeChanged } + } + this.entries.delete(key) + this.entries.set(key, entry) + return { contents: entry.contents, scopeChanged } + } + + beginRequest(scope: string, key: string): MobilePrFileContentRequestToken { + this.activateScope(scope) + const token = { scope, key, requestId: ++this.requestSequence } + this.activeRequest = token + return token + } + + commitRequest( + token: MobilePrFileContentRequestToken, + contents: GitHubPRFileContents + ): 'stored' | 'stale' | 'too-large' { + if (!this.isCurrentRequest(token)) { + return 'stale' + } + this.activeRequest = null + const byteCount = getMobilePrFileContentByteCount(contents) + if (byteCount > this.maxBytes) { + return 'too-large' + } + const previous = this.entries.get(token.key) + this.retainedBytes -= previous?.byteCount ?? 0 + this.entries.delete(token.key) + this.entries.set(token.key, { contents, byteCount }) + this.retainedBytes += byteCount + this.evictOverflow() + return 'stored' + } + + rejectRequest(token: MobilePrFileContentRequestToken): boolean { + if (!this.isCurrentRequest(token)) { + return false + } + this.activeRequest = null + return true + } + + snapshot(): MobilePrFileContentCacheSnapshot { + return { + scope: this.scope, + contentsByKey: Object.fromEntries( + [...this.entries].map(([key, entry]) => [key, entry.contents]) + ) + } + } + + evidence(): MobilePrFileContentCacheEvidence { + return { + scope: this.scope, + entryCount: this.entries.size, + retainedBytes: this.retainedBytes, + keysOldestFirst: [...this.entries.keys()] + } + } + + private isCurrentRequest(token: MobilePrFileContentRequestToken): boolean { + return ( + this.scope === token.scope && + this.activeRequest?.requestId === token.requestId && + this.activeRequest.scope === token.scope && + this.activeRequest.key === token.key + ) + } + + private evictOverflow(): void { + while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxBytes) { + const oldestKey = this.entries.keys().next().value + if (typeof oldestKey !== 'string') { + return + } + const oldest = this.entries.get(oldestKey) + this.retainedBytes -= oldest?.byteCount ?? 0 + this.entries.delete(oldestKey) + } + } +} + +export function createMobilePrFileContentScope(input: MobilePrFileContentScopeInput): string { + return JSON.stringify([ + input.source, + input.repoId, + input.prNumber, + input.repository?.host?.toLowerCase() ?? '', + input.repository?.owner.toLowerCase() ?? '', + input.repository?.repo.toLowerCase() ?? '', + input.headSha, + input.baseSha + ]) +} + +export function createMobilePrFileContentKey(input: MobilePrFileContentKeyInput): string { + return input.path +} + +export function getMobilePrFileContentsForScope( + snapshot: MobilePrFileContentCacheSnapshot, + scope: string | null +): Readonly> { + return scope !== null && snapshot.scope === scope ? snapshot.contentsByKey : {} +} + +export function getMobilePrFileContentByteCount(contents: GitHubPRFileContents): number { + return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified) +} + +function getUtf8ByteCount(value: string): number { + let byteCount = 0 + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code < 0x80) { + byteCount += 1 + } else if (code < 0x800) { + byteCount += 2 + } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + byteCount += 4 + index += 1 + } else { + byteCount += 3 + } + } else { + byteCount += 3 + } + } + return byteCount +} diff --git a/mobile/src/tasks/use-mobile-pr-file-content-cache.ts b/mobile/src/tasks/use-mobile-pr-file-content-cache.ts new file mode 100644 index 00000000000..d364324336b --- /dev/null +++ b/mobile/src/tasks/use-mobile-pr-file-content-cache.ts @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useState } from 'react' +import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types' +import { + MobilePrFileContentCache, + createMobilePrFileContentKey, + createMobilePrFileContentScope, + getMobilePrFileContentsForScope, + type MobilePrFileContentKeyInput +} from './mobile-pr-file-content-cache' + +type MobilePrScopeTaskItem = { + provider: string + source: unknown +} | null + +type MobilePrScopeDetail = { + provider: string + headSha?: unknown + baseSha?: unknown +} | null + +type MobileProjectPrScopeItem = { + itemType: string + content: { number?: unknown } +} | null + +type MobileProjectPrScopeRepo = { id?: unknown } | null + +type MobilePrFileContentLoad = () => Promise +type MobilePrFileContentErrorSetter = (message: string) => void + +export function createMobileItemPrFileContentScope( + item: MobilePrScopeTaskItem, + detail: MobilePrScopeDetail +): string | null { + const source = + item?.source && typeof item.source === 'object' + ? (item.source as { type?: unknown; repoId?: unknown; number?: unknown }) + : null + if ( + item?.provider !== 'github' || + source?.type !== 'pr' || + typeof source.repoId !== 'string' || + typeof source.number !== 'number' || + detail?.provider !== 'github' || + typeof detail.headSha !== 'string' || + !detail.headSha || + typeof detail.baseSha !== 'string' || + !detail.baseSha + ) { + return null + } + return createMobilePrFileContentScope({ + source: 'item', + repoId: source.repoId, + prNumber: source.number, + headSha: detail.headSha, + baseSha: detail.baseSha + }) +} + +export function createMobileProjectPrFileContentScope( + item: MobileProjectPrScopeItem, + repo: MobileProjectPrScopeRepo, + detail: MobilePrScopeDetail, + repository?: GitHubRepositoryIdentity | null +): string | null { + if ( + item?.itemType !== 'PULL_REQUEST' || + typeof item.content.number !== 'number' || + typeof repo?.id !== 'string' || + detail?.provider !== 'github' || + typeof detail.headSha !== 'string' || + !detail.headSha || + typeof detail.baseSha !== 'string' || + !detail.baseSha + ) { + return null + } + return createMobilePrFileContentScope({ + source: 'project', + repoId: repo.id, + prNumber: item.content.number, + repository, + headSha: detail.headSha, + baseSha: detail.baseSha + }) +} + +export function useMobilePrFileContentCache(activeScope: string | null): { + clear: () => void + contents: Readonly> + load: ( + scope: string, + file: MobilePrFileContentKeyInput, + loadContents: MobilePrFileContentLoad, + setError: MobilePrFileContentErrorSetter + ) => Promise + loadingPath: string | null +} { + const [cache] = useState(() => new MobilePrFileContentCache()) + const [snapshot, setSnapshot] = useState(() => cache.snapshot()) + const [loadingPath, setLoadingPath] = useState(null) + const clear = useCallback(() => { + cache.clear() + setSnapshot(cache.snapshot()) + setLoadingPath(null) + }, [cache]) + + useEffect(() => { + if (activeScope === null) { + clear() + } else if (cache.activateScope(activeScope)) { + setSnapshot(cache.snapshot()) + setLoadingPath(null) + } + }, [activeScope, cache, clear]) + + const load = useCallback( + async ( + scope: string, + file: MobilePrFileContentKeyInput, + loadContents: MobilePrFileContentLoad, + setError: MobilePrFileContentErrorSetter + ): Promise => { + const key = createMobilePrFileContentKey(file) + const selection = cache.select(scope, key) + if (selection.scopeChanged) { + setSnapshot(cache.snapshot()) + } + if (selection.contents) { + setLoadingPath(null) + return + } + const token = cache.beginRequest(scope, key) + setLoadingPath(file.path) + setError('') + try { + const result = await loadContents() + if (!isGitHubPrFileContents(result)) { + throw new Error('Invalid file contents response') + } + const commit = cache.commitRequest(token, result) + if (commit === 'stale') { + return + } + if (commit === 'too-large') { + setError('File too large for mobile preview.') + } else { + setSnapshot(cache.snapshot()) + } + setLoadingPath((current) => (current === file.path ? null : current)) + } catch (error) { + if (!cache.rejectRequest(token)) { + return + } + setError(error instanceof Error ? error.message : 'Failed to load file contents') + setLoadingPath((current) => (current === file.path ? null : current)) + } + }, + [cache] + ) + + return { + clear, + contents: getMobilePrFileContentsForScope(snapshot, activeScope), + load, + loadingPath + } +} + +function isGitHubPrFileContents(value: unknown): value is GitHubPRFileContents { + if (!value || typeof value !== 'object') { + return false + } + const contents = value as Partial + return ( + typeof contents.original === 'string' && + typeof contents.modified === 'string' && + typeof contents.originalIsBinary === 'boolean' && + typeof contents.modifiedIsBinary === 'boolean' + ) +} diff --git a/mobile/src/terminal/terminal-webview-default-theme.ts b/mobile/src/terminal/terminal-webview-default-theme.ts new file mode 100644 index 00000000000..0769f4283ed --- /dev/null +++ b/mobile/src/terminal/terminal-webview-default-theme.ts @@ -0,0 +1,27 @@ +import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import { colors } from '../theme/mobile-theme' + +export const DEFAULT_TERMINAL_WEBVIEW_THEME: RuntimeMobileTerminalTheme['theme'] = { + background: colors.terminalBg, + foreground: '#c0caf5', + cursor: '#c0caf5', + cursorAccent: colors.terminalBg, + selectionBackground: '#33467c', + selectionForeground: '#c0caf5', + black: '#15161e', + red: '#f7768e', + green: '#9ece6a', + yellow: '#e0af68', + blue: '#7aa2f7', + magenta: '#bb9af7', + cyan: '#7dcfff', + white: '#a9b1d6', + brightBlack: '#414868', + brightRed: '#f7768e', + brightGreen: '#9ece6a', + brightYellow: '#e0af68', + brightBlue: '#7aa2f7', + brightMagenta: '#bb9af7', + brightCyan: '#7dcfff', + brightWhite: '#c0caf5' +} diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 767b2cc6b6c..9d66610dd0e 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,7 +1,7 @@ -// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget. -import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +// xterm.js WebView document; extracted from TerminalWebView.tsx for the max-lines budget. import { colors } from '../theme/mobile-theme' import { TERMINAL_TEXT_SCALES } from '../storage/preferences' +import { DEFAULT_TERMINAL_WEBVIEW_THEME } from './terminal-webview-default-theme' import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' @@ -12,30 +12,9 @@ import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' -const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { - background: colors.terminalBg, - foreground: '#c0caf5', - cursor: '#c0caf5', - cursorAccent: colors.terminalBg, - selectionBackground: '#33467c', - selectionForeground: '#c0caf5', - black: '#15161e', - red: '#f7768e', - green: '#9ece6a', - yellow: '#e0af68', - blue: '#7aa2f7', - magenta: '#bb9af7', - cyan: '#7dcfff', - white: '#a9b1d6', - brightBlack: '#414868', - brightRed: '#f7768e', - brightGreen: '#9ece6a', - brightYellow: '#e0af68', - brightBlue: '#7aa2f7', - brightMagenta: '#bb9af7', - brightCyan: '#7dcfff', - brightWhite: '#c0caf5' -} +export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS = 1_000_000 +export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES = 4_096 +export const TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS = 256 // Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing. export const XTERM_HTML = ` @@ -213,10 +192,11 @@ window.onerror = function(msg) { var scrollIndicator = document.getElementById('scroll-indicator'); var scrollThumb = document.getElementById('scroll-thumb'); var scrollIndicatorHideTimer = null; - var writeQueue = []; + var writeQueue = [], writeQueueUnits = 0; var writeQueueHead = 0; - var writesDraining = false; - var afterDrainCallbacks = []; + var writesDraining = false, writeBacklogFailed = false, afterDrainCallbacks = []; + var WRITE_QUEUE_MAX_UNITS = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS}, WRITE_QUEUE_MAX_ENTRIES = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES}; + var AFTER_DRAIN_MAX_CALLBACKS = ${TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS}; var termObserverDisposables = []; var ready = false; // Why: init() flips ready false on every re-init (live width reflow included) @@ -287,7 +267,7 @@ window.onerror = function(msg) { var normalScrollFrameId = null; var initRows = 24; var terminalGeneration = 0; - var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)}; + var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_WEBVIEW_THEME)}; var terminalThemeInput = null; var terminalTheme = defaultTheme; var terminalMinimumContrastRatio = 3; @@ -557,9 +537,22 @@ ${TERMINAL_WEBVIEW_THEME_JS} } } - function resetWriteQueue() { - writeQueue = []; - writeQueueHead = 0; + function resetWriteQueue() { writeQueue = []; writeQueueHead = 0; writeQueueUnits = 0; } + + function failWriteBacklog() { + if (writeBacklogFailed) return; + writeBacklogFailed = true; terminalGeneration++; ready = false; writesDraining = false; + resetWriteQueue(); afterDrainCallbacks = []; + reportEngineError('terminal write backlog exceeded safe limit', null, true); + } + + function reserveWriteQueueEntry(units) { + if (writeBacklogFailed) return false; + var pendingEntries = writeQueue.length - writeQueueHead; + if (pendingEntries >= WRITE_QUEUE_MAX_ENTRIES || writeQueueUnits + units > WRITE_QUEUE_MAX_UNITS) { + failWriteBacklog(); return false; + } + writeQueueUnits += units; return true; } function isStatusDotPresentationSelector(value) { @@ -590,13 +583,12 @@ ${TERMINAL_WEBVIEW_THEME_JS} return normalized; } - function enqueueWrite(data) { - writeQueue.push(normalizeStatusDotPresentation(data)); + function enqueueWrite(data) { var normalized = normalizeStatusDotPresentation(data); + if (!reserveWriteQueueEntry(normalized.length)) return false; + writeQueue.push(normalized); return true; } - function enqueueWriteBoundary(callback) { - writeQueue.push(callback); - } + function enqueueWriteBoundary(callback) { if (!reserveWriteQueueEntry(0)) return false; writeQueue.push(callback); return true; } function nextQueuedWrite() { if (writeQueueHead >= writeQueue.length) { @@ -605,6 +597,7 @@ ${TERMINAL_WEBVIEW_THEME_JS} } var next = writeQueue[writeQueueHead]; writeQueueHead++; + if (typeof next === 'string') writeQueueUnits = Math.max(0, writeQueueUnits - next.length); // Why: high-throughput terminals can enqueue faster than xterm parses; // compact consumed slots so drain work stays O(1) without retaining old chunks. if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { @@ -660,8 +653,8 @@ ${TERMINAL_WEBVIEW_THEME_JS} } function afterWritesDrained(callback) { - afterDrainCallbacks.push(callback); - pumpWrites(terminalGeneration); + if (afterDrainCallbacks.length >= AFTER_DRAIN_MAX_CALLBACKS) { failWriteBacklog(); return; } + afterDrainCallbacks.push(callback); pumpWrites(terminalGeneration); } ${TERMINAL_WEBGL_RECOVERY_JS} @@ -682,6 +675,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} webglAddon = null; ready = false; resetWriteQueue(); + writeBacklogFailed = false; statusDotPendingSelector = false; writesDraining = false; afterDrainCallbacks = []; @@ -726,9 +720,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} disableStdin: false, cursorBlink: false, cursorStyle: 'bar', - // Why: native TextInput owns mobile keyboard focus, so xterm stays inactive. - // Match its active bar while still honoring application cursor-hide sequences. - cursorInactiveStyle: 'bar', + cursorInactiveStyle: 'none', convertEol: false, allowProposedApi: true }); @@ -770,7 +762,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} function write(data) { updateMouseModeFromData(data); - enqueueWrite(data); + if (!enqueueWrite(data)) return; pumpWrites(terminalGeneration); // Why: first live data chunk after init may widen the buffer past // what the post-replay applyFitScale measured. Re-fit once after this @@ -934,6 +926,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} } else if (msg.type === 'clear') { terminalGeneration++; resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary. + writeBacklogFailed = false; statusDotPendingSelector = false; afterDrainCallbacks = []; writesDraining = false; diff --git a/mobile/src/terminal/terminal-webview-init-surface.test.ts b/mobile/src/terminal/terminal-webview-init-surface.test.ts index 9aa23e1fc53..6c7c4e5f173 100644 --- a/mobile/src/terminal/terminal-webview-init-surface.test.ts +++ b/mobile/src/terminal/terminal-webview-init-surface.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { XTERM_HTML } from './terminal-webview-html' function iifeSource(): string { @@ -15,15 +15,6 @@ function bodyMarkup(): string { } type TerminalStub = ReturnType -type TerminalOptions = { - cursorInactiveStyle?: string - cursorStyle?: string -} -type RegisteredWindowListener = { - listener: EventListenerOrEventListenerObject - options?: boolean | AddEventListenerOptions - type: string -} function makeTerminal(writeCallbacks: Array<() => void>) { const terminal = { @@ -88,26 +79,13 @@ function dispatchInit(cols: number, initialData: string): void { describe('terminal WebView init surface replacement', () => { let animationFrames: Array<() => void> - let registeredWindowListeners: RegisteredWindowListener[] - let terminalOptions: TerminalOptions[] let terminals: TerminalStub[] let writeCallbacks: Array<() => void> beforeEach(() => { animationFrames = [] - registeredWindowListeners = [] - terminalOptions = [] terminals = [] writeCallbacks = [] - const addWindowEventListener = window.addEventListener.bind(window) - vi.spyOn(window, 'addEventListener').mockImplementation((( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions - ) => { - registeredWindowListeners.push({ type, listener, options }) - addWindowEventListener(type, listener, options) - }) as typeof window.addEventListener) vi.stubGlobal('requestAnimationFrame', (callback: () => void) => { animationFrames.push(callback) return animationFrames.length @@ -115,42 +93,20 @@ describe('terminal WebView init surface replacement', () => { Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true }) Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true }) const webWindow = window as unknown as { - Terminal: new (options: TerminalOptions) => TerminalStub + Terminal: new () => TerminalStub ReactNativeWebView: { postMessage: (data: string) => void } } - webWindow.Terminal = function (options: TerminalOptions) { - terminalOptions.push(options) + webWindow.Terminal = function () { const terminal = makeTerminal(writeCallbacks) terminals.push(terminal) return terminal - } as unknown as new (options: TerminalOptions) => TerminalStub + } as unknown as new () => TerminalStub webWindow.ReactNativeWebView = { postMessage: vi.fn() } document.body.innerHTML = bodyMarkup() // eslint-disable-next-line no-new-func new Function(iifeSource())() }) - afterEach(() => { - for (const { type, listener, options } of registeredWindowListeners) { - window.removeEventListener(type, listener as EventListener, options) - } - vi.restoreAllMocks() - }) - - it("keeps xterm's inactive cursor visible across replacement surfaces", () => { - dispatchInit(120, 'desktop') - dispatchInit(51, 'phone-resize') - dispatchInit(51, 'phone-scrollback') - - expect(terminalOptions).toHaveLength(3) - for (const options of terminalOptions) { - expect(options).toMatchObject({ - cursorStyle: 'bar', - cursorInactiveStyle: 'bar' - }) - } - }) - it('commits only the newest surface when phone-fit init calls overlap', () => { // Why: restored terminals can receive desktop scrollback, a phone resize, // and phone scrollback before any xterm replay callback has completed. diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 09aeb82d3f7..1f6ba4fac01 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -91,7 +91,8 @@ describe('TerminalWebView text zoom', () => { expect(terminalHtmlSource).toContain( 'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)' ) - expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))') + expect(terminalHtmlSource).toContain('var normalized = normalizeStatusDotPresentation(data)') + expect(terminalHtmlSource).toContain('writeQueue.push(normalized)') }) it('normalizes Claude status dots idempotently across write chunks', () => { diff --git a/mobile/src/transport/bounded-process-line-reader.test.ts b/mobile/src/transport/bounded-process-line-reader.test.ts new file mode 100644 index 00000000000..3fd0083e134 --- /dev/null +++ b/mobile/src/transport/bounded-process-line-reader.test.ts @@ -0,0 +1,46 @@ +import { once } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { + appendProcessOutputTail, + attachBoundedProcessLineReader, + PROCESS_LINE_MAX_BYTES +} from '../../scripts/bounded-process-line-reader.mjs' + +describe('attachBoundedProcessLineReader', () => { + it('preserves ordinary LF, CRLF, and split UTF-8 lines', () => { + const stream = new PassThrough() + const lines: string[] = [] + attachBoundedProcessLineReader(stream, (line) => lines.push(line)) + + const unicode = Buffer.from('café\n') + stream.write('first\r\nsecond\r') + stream.write('\n') + stream.write(unicode.subarray(0, -2)) + stream.end(unicode.subarray(-2)) + + expect(lines).toEqual(['first', 'second', 'café']) + }) + + it('retains only the configured prefix of an unterminated line', async () => { + const stream = new PassThrough() + const lines: string[] = [] + attachBoundedProcessLineReader(stream, (line) => lines.push(line), 4) + + const ended = once(stream, 'end') + stream.end('abcdefgh') + await ended + + expect(lines).toEqual(['abcd… [line truncated]']) + }) + + it('uses a bounded default for process output lines', () => { + expect(PROCESS_LINE_MAX_BYTES).toBe(64 * 1024) + }) + + it('keeps exact output below the tail cap and newest output above it', () => { + expect(appendProcessOutputTail('first\n', 'second', 32)).toBe('first\nsecond\n') + expect(appendProcessOutputTail('first\n', 'second', 8)).toBe('\nsecond\n') + expect(appendProcessOutputTail('first\n', 'second', 0)).toBe('') + }) +}) diff --git a/mobile/src/transport/bounded-response-body.test.ts b/mobile/src/transport/bounded-response-body.test.ts new file mode 100644 index 00000000000..183c1dedc61 --- /dev/null +++ b/mobile/src/transport/bounded-response-body.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { responseBodyIncludesWithinLimit } from '../../scripts/bounded-response-body.mjs' + +function responseWithChunks(chunks: string[], contentLength?: number): Response { + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)) + } + controller.close() + } + }), + { headers: contentLength === undefined ? {} : { 'content-length': String(contentLength) } } + ) +} + +describe('responseBodyIncludesWithinLimit', () => { + it('matches a marker split across admitted chunks', async () => { + const response = responseWithChunks(['packager-', 'status:running']) + await expect( + responseBodyIncludesWithinLimit(response, 'packager-status:running', 64) + ).resolves.toBe(true) + }) + + it('rejects streamed and declared bodies above the byte limit', async () => { + const streamed = responseWithChunks(['1234', '5']) + const declared = responseWithChunks(['ok'], 5) + + await expect(responseBodyIncludesWithinLimit(streamed, 'missing', 4)).resolves.toBe(false) + await expect(responseBodyIncludesWithinLimit(declared, 'ok', 4)).resolves.toBe(false) + }) +}) diff --git a/mobile/src/transport/browser-screencast-protocol.ts b/mobile/src/transport/browser-screencast-protocol.ts index 01ee4353f6c..7cde80ec547 100644 --- a/mobile/src/transport/browser-screencast-protocol.ts +++ b/mobile/src/transport/browser-screencast-protocol.ts @@ -1,3 +1,5 @@ +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' + const BROWSER_SCREENCAST_KIND = 0x62 const BROWSER_SCREENCAST_VERSION = 1 const HEADER_BYTES = 16 @@ -51,7 +53,7 @@ function byteToFormat(value: number): BrowserScreencastFormat | null { function decodeJson(bytes: Uint8Array): unknown { try { - return JSON.parse(new TextDecoder().decode(bytes)) as unknown + return parseMobileJsonTextWithinLimits(new TextDecoder().decode(bytes)) } catch { return null } diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts index e7ce662534b..1f7d8762d4f 100644 --- a/mobile/src/transport/connection-log-buffer.test.ts +++ b/mobile/src/transport/connection-log-buffer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { createConnectionLogStore } from './connection-log-buffer' +import { CONNECTION_LOG_HOST_ID_MAX_BYTES, createConnectionLogStore } from './connection-log-buffer' import type { ConnectionLogEntry } from './types' function entry(id: number): ConnectionLogEntry { @@ -53,4 +53,81 @@ describe('connection log buffer', () => { store.append('host-a', entry(2)) expect(onA).toHaveBeenCalledTimes(1) }) + + it('evicts the least-recently-written inactive host past the host cap', () => { + const store = createConnectionLogStore(3, 2) + store.append('host-a', entry(1)) + store.append('host-b', entry(2)) + store.append('host-a', entry(3)) + store.append('host-c', entry(4)) + + expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-3']) + expect(store.get('host-b')).toEqual([]) + expect(store.get('host-c').map((e) => e.id)).toEqual(['log-4']) + }) + + it('drops removed-host entries and refreshes an active snapshot', () => { + const store = createConnectionLogStore() + const listener = vi.fn() + store.append('host-a', entry(1)) + store.subscribe('host-a', listener) + + store.delete('host-a') + + expect(store.get('host-a')).toEqual([]) + expect(listener).toHaveBeenCalledOnce() + }) + + it('accepts the exact host-id limit and rejects one more byte', () => { + const store = createConnectionLogStore() + const exactHost = 'h'.repeat(CONNECTION_LOG_HOST_ID_MAX_BYTES) + + store.append(exactHost, entry(1)) + store.append(`${exactHost}h`, entry(2)) + + expect(store.get(exactHost).map((value) => value.id)).toEqual(['log-1']) + expect(store.get(`${exactHost}h`)).toEqual([]) + }) + + it('accepts an exact entry-byte budget and rejects one more byte', () => { + const store = createConnectionLogStore(10, 10, { + maxEntryBytes: 260, + maxHostBytes: 1024, + maxStoreBytes: 2048 + }) + const exact: ConnectionLogEntry = { id: '', ts: 1, level: 'info', message: '' } + + store.append('host-a', exact) + store.append('host-a', { ...exact, message: 'x' }) + + expect(store.get('host-a')).toEqual([exact]) + }) + + it('keeps newest entries within the per-host byte budget', () => { + const store = createConnectionLogStore(10, 10, { + maxEntryBytes: 1024, + maxHostBytes: 700, + maxStoreBytes: 4096 + }) + + store.append('host-a', entry(1)) + store.append('host-a', entry(2)) + store.append('host-a', entry(3)) + + expect(store.get('host-a').map((value) => value.id)).toEqual(['log-2', 'log-3']) + }) + + it('evicts inactive hosts to stay within the aggregate byte budget', () => { + const store = createConnectionLogStore(10, 10, { + maxEntryBytes: 1024, + maxHostBytes: 1024, + maxStoreBytes: 700 + }) + + store.append('host-a', entry(1)) + store.append('host-b', entry(2)) + + expect(store.get('host-a')).toEqual([]) + expect(store.get('host-b').map((value) => value.id)).toEqual(['log-2']) + }) }) diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts index 1b223ad8e1f..a085c989cb1 100644 --- a/mobile/src/transport/connection-log-buffer.ts +++ b/mobile/src/transport/connection-log-buffer.ts @@ -1,4 +1,5 @@ import type { ConnectionLogEntry } from './types' +import { measureUtf8ByteLength } from '../../../src/shared/memory-safety/utf8-byte-limits' // Why: the rpc-client's onLog entries were only wired during pairing; for // long-lived host connections everything went to console.log, invisible to @@ -8,35 +9,159 @@ import type { ConnectionLogEntry } from './types' // swaps (forceReconnect) and provider remounts (hot reload); bounded so an // all-night reconnect loop can't grow memory unbounded. const MAX_ENTRIES_PER_HOST = 200 +const MAX_RETAINED_HOSTS = 128 +export const CONNECTION_LOG_HOST_ID_MAX_BYTES = 4 * 1024 +export const CONNECTION_LOG_ENTRY_MAX_BYTES = 64 * 1024 +export const CONNECTION_LOG_HOST_MAX_RETAINED_BYTES = 256 * 1024 +export const CONNECTION_LOG_STORE_MAX_RETAINED_BYTES = 8 * 1024 * 1024 +const CONNECTION_LOG_MAX_LISTENERS_PER_HOST = 16 + +type RetainedConnectionLogEntry = { + entry: ConnectionLogEntry + bytes: number +} + +type RetainedHostLog = { + entries: RetainedConnectionLogEntry[] + bytes: number +} + +type ConnectionLogByteLimits = { + maxEntryBytes: number + maxHostBytes: number + maxStoreBytes: number +} + +function measureBoundedString(value: unknown, maxBytes: number): number | null { + if (value === undefined) { + return 0 + } + if (typeof value !== 'string') { + return null + } + const measurement = measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }) + return measurement.exceededLimit ? null : measurement.byteLength +} + +function measureConnectionLogEntry(entry: ConnectionLogEntry, maxBytes: number): number | null { + let bytes = 256 + for (const value of [entry.id, entry.level, entry.message, entry.detail]) { + const valueBytes = measureBoundedString(value, maxBytes - bytes) + if (valueBytes === null) { + return null + } + bytes += valueBytes + } + return bytes <= maxBytes ? bytes : null +} export type ConnectionLogStore = { append: (hostId: string, entry: ConnectionLogEntry) => void get: (hostId: string) => readonly ConnectionLogEntry[] subscribe: (hostId: string, listener: () => void) => () => void + delete: (hostId: string) => void } export function createConnectionLogStore( - maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST + maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST, + maxRetainedHosts: number = MAX_RETAINED_HOSTS, + byteLimits: Partial = {} ): ConnectionLogStore { - const entriesByHost = new Map() + const maxEntryBytes = Math.min( + byteLimits.maxEntryBytes ?? CONNECTION_LOG_ENTRY_MAX_BYTES, + CONNECTION_LOG_ENTRY_MAX_BYTES + ) + const maxHostBytes = Math.min( + byteLimits.maxHostBytes ?? CONNECTION_LOG_HOST_MAX_RETAINED_BYTES, + CONNECTION_LOG_HOST_MAX_RETAINED_BYTES + ) + const maxStoreBytes = Math.min( + byteLimits.maxStoreBytes ?? CONNECTION_LOG_STORE_MAX_RETAINED_BYTES, + CONNECTION_LOG_STORE_MAX_RETAINED_BYTES + ) + const entriesByHost = new Map() const listenersByHost = new Map void>>() // Why: useSyncExternalStore compares snapshots by reference — getSnapshot // must return the SAME array until the data actually changes, or React // loops re-rendering. Cache per host; invalidate on append. const snapshotByHost = new Map() const EMPTY: readonly ConnectionLogEntry[] = [] + let retainedStoreBytes = 0 + + const deleteHost = (hostId: string): boolean => { + const host = entriesByHost.get(hostId) + if (!host) { + return false + } + entriesByHost.delete(hostId) + retainedStoreBytes -= host.bytes + snapshotByHost.delete(hostId) + return true + } + + const evictOldestUnobservedHost = (exceptHostId?: string): boolean => { + for (const hostId of entriesByHost.keys()) { + if (hostId !== exceptHostId && !listenersByHost.has(hostId)) { + return deleteHost(hostId) + } + } + return false + } return { append(hostId, entry) { - let entries = entriesByHost.get(hostId) - if (!entries) { - entries = [] - entriesByHost.set(hostId, entries) + const hostIdBytes = measureBoundedString(hostId, CONNECTION_LOG_HOST_ID_MAX_BYTES) + const entryBytes = measureConnectionLogEntry(entry, maxEntryBytes) + if (hostIdBytes === null || entryBytes === null || entryBytes > maxHostBytes) { + return } - entries.push(entry) - if (entries.length > maxEntriesPerHost) { - entries.splice(0, entries.length - maxEntriesPerHost) + let host = entriesByHost.get(hostId) + if (!host) { + while (entriesByHost.size >= maxRetainedHosts) { + if (!evictOldestUnobservedHost()) { + return + } + } + const retainedHostKeyBytes = hostIdBytes + 128 + while (retainedStoreBytes + retainedHostKeyBytes + entryBytes > maxStoreBytes) { + if (!evictOldestUnobservedHost()) { + return + } + } + host = { entries: [], bytes: retainedHostKeyBytes } + entriesByHost.set(hostId, host) + retainedStoreBytes += retainedHostKeyBytes + } else { + entriesByHost.delete(hostId) + entriesByHost.set(hostId, host) } + while ( + host.entries.length >= maxEntriesPerHost || + host.bytes + entryBytes > maxHostBytes || + retainedStoreBytes + entryBytes > maxStoreBytes + ) { + const oldest = host.entries.shift() + if (oldest) { + host.bytes -= oldest.bytes + retainedStoreBytes -= oldest.bytes + continue + } + if (!evictOldestUnobservedHost(hostId)) { + break + } + } + if ( + host.bytes + entryBytes > maxHostBytes || + retainedStoreBytes + entryBytes > maxStoreBytes + ) { + if (host.entries.length === 0) { + deleteHost(hostId) + } + return + } + host.entries.push({ entry, bytes: entryBytes }) + host.bytes += entryBytes + retainedStoreBytes += entryBytes snapshotByHost.delete(hostId) const listeners = listenersByHost.get(hostId) if (listeners) { @@ -51,21 +176,30 @@ export function createConnectionLogStore( if (cached) { return cached } - const entries = entriesByHost.get(hostId) - if (!entries || entries.length === 0) { + const host = entriesByHost.get(hostId) + if (!host || host.entries.length === 0) { return EMPTY } - const snapshot = Object.freeze([...entries]) + const snapshot = Object.freeze(host.entries.map((retained) => retained.entry)) snapshotByHost.set(hostId, snapshot) return snapshot }, subscribe(hostId, listener) { + if (measureBoundedString(hostId, CONNECTION_LOG_HOST_ID_MAX_BYTES) === null) { + return () => {} + } let listeners = listenersByHost.get(hostId) if (!listeners) { + if (listenersByHost.size >= maxRetainedHosts) { + return () => {} + } listeners = new Set() listenersByHost.set(hostId, listeners) } + if (listeners.size >= CONNECTION_LOG_MAX_LISTENERS_PER_HOST) { + return () => {} + } listeners.add(listener) return () => { const set = listenersByHost.get(hostId) @@ -77,6 +211,16 @@ export function createConnectionLogStore( listenersByHost.delete(hostId) } } + }, + + delete(hostId) { + deleteHost(hostId) + const listeners = listenersByHost.get(hostId) + if (listeners) { + for (const listener of listeners) { + listener() + } + } } } } diff --git a/mobile/src/transport/e2ee.ts b/mobile/src/transport/e2ee.ts index 2732b3d615f..d36cb577341 100644 --- a/mobile/src/transport/e2ee.ts +++ b/mobile/src/transport/e2ee.ts @@ -4,6 +4,10 @@ // stream frames use the raw byte bundle. import nacl from 'tweetnacl' import * as ExpoCrypto from 'expo-crypto' +import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue' + +export const MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS = + Math.ceil(MOBILE_INBOUND_MAX_FRAME_BYTES / 3) * 4 // Why: Hermes (React Native's JS engine) lacks crypto.getRandomValues, // which tweetnacl requires. expo-crypto provides a native secure RNG @@ -39,8 +43,14 @@ function uint8ToBase64(bytes: Uint8Array): string { return btoa(binary) } -function base64ToUint8(b64: string): Uint8Array { +function base64ToUint8(b64: string, maxDecodedBytes: number): Uint8Array { + if (b64.length > Math.ceil(maxDecodedBytes / 3) * 4) { + throw new Error('Base64 payload exceeds safe size') + } const binary = atob(b64) + if (binary.length > maxDecodedBytes) { + throw new Error('Base64 payload exceeds safe size') + } const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) @@ -49,7 +59,7 @@ function base64ToUint8(b64: string): Uint8Array { } export function publicKeyFromBase64(b64: string): Uint8Array { - const key = base64ToUint8(b64) + const key = base64ToUint8(b64, 32) if (key.length !== 32) { throw new Error( `Invalid public key: expected 32 bytes, got ${key.length} from "${b64.slice(0, 20)}..."` @@ -68,7 +78,10 @@ export function encrypt(plaintext: string, sharedKey: Uint8Array): string { } export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { - const bundle = base64ToUint8(encrypted) + if (encrypted.length > MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS) { + throw new Error('Base64 payload exceeds safe size') + } + const bundle = base64ToUint8(encrypted, MOBILE_INBOUND_MAX_FRAME_BYTES) const plaintext = decryptBytes(bundle, sharedKey) return plaintext ? new TextDecoder().decode(plaintext) : null } diff --git a/mobile/src/transport/host-client-open-registry.test.ts b/mobile/src/transport/host-client-open-registry.test.ts new file mode 100644 index 00000000000..f70cef41f95 --- /dev/null +++ b/mobile/src/transport/host-client-open-registry.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { HostClientOpenRegistry } from './host-client-open-registry' + +describe('HostClientOpenRegistry', () => { + it('cancels replaced and oldest tickets while bounding pending hosts', () => { + const registry = new HostClientOpenRegistry(2) + const first = registry.register('host-a', new Promise(() => {})) + const replacement = registry.register('host-a', new Promise(() => {})) + const second = registry.register('host-b', new Promise(() => {})) + const third = registry.register('host-c', new Promise(() => {})) + + expect(first.cancelled).toBe(true) + expect(replacement.cancelled).toBe(true) + expect(second.cancelled).toBe(false) + expect(third.cancelled).toBe(false) + expect(registry.getActivePromise('host-a')).toBeNull() + expect(registry.getActivePromise('host-b')).toBe(second.promise) + expect(registry.getActivePromise('host-c')).toBe(third.promise) + }) +}) diff --git a/mobile/src/transport/host-client-open-registry.ts b/mobile/src/transport/host-client-open-registry.ts index a47ca5a231a..ac9c6a16c96 100644 --- a/mobile/src/transport/host-client-open-registry.ts +++ b/mobile/src/transport/host-client-open-registry.ts @@ -3,15 +3,35 @@ export type HostClientOpenTicket = { promise: Promise } +export const HOST_CLIENT_OPEN_MAX_PENDING = 64 + export class HostClientOpenRegistry { private readonly pending = new Map() + constructor(private readonly maxPending = HOST_CLIENT_OPEN_MAX_PENDING) {} + getActivePromise(hostId: string): Promise | null { const ticket = this.pending.get(hostId) return ticket && !ticket.cancelled ? ticket.promise : null } register(hostId: string, promise: Promise): HostClientOpenTicket { + const prior = this.pending.get(hostId) + if (prior) { + prior.cancelled = true + this.pending.delete(hostId) + } + while (this.pending.size >= Math.max(1, this.maxPending)) { + const oldestHostId = this.pending.keys().next().value as string | undefined + if (oldestHostId === undefined) { + break + } + const oldest = this.pending.get(oldestHostId) + if (oldest) { + oldest.cancelled = true + } + this.pending.delete(oldestHostId) + } const ticket = { cancelled: false, promise } this.pending.set(hostId, ticket) return ticket diff --git a/mobile/src/transport/host-credential-cleanup.test.ts b/mobile/src/transport/host-credential-cleanup.test.ts index bd483a3280f..2ba0664522d 100644 --- a/mobile/src/transport/host-credential-cleanup.test.ts +++ b/mobile/src/transport/host-credential-cleanup.test.ts @@ -1,6 +1,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES, + HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS, loadPendingHostCredentialCleanup, loadPendingHostCredentialCleanupIds, resetHostCredentialCleanupForTests, @@ -270,4 +272,41 @@ describe('host credential cleanup', () => { await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) }) + + it('accepts the exact pending-id cap and treats one over as unreadable', async () => { + storedPendingIds = Array.from( + { length: HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS }, + (_, index) => `host-${index}` + ) + + await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({ + ids: storedPendingIds, + storageUnreadable: false + }) + + storedPendingIds = [...storedPendingIds, 'one-over'] + await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({ + ids: [], + storageUnreadable: true + }) + }) + + it('caps simultaneous native deletes while preserving excess durable intents', async () => { + vi.useFakeTimers() + const deleteCredential = vi.fn(() => new Promise(() => undefined)) + const hostCount = HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES + 1 + + await Promise.all( + Array.from({ length: hostCount }, (_, index) => + scheduleHostCredentialCleanup(`host-${index}`, deleteCredential, 3_000) + ) + ) + await flushMicrotasks() + + expect(deleteCredential).toHaveBeenCalledTimes(HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES) + expect(storedPendingIds).toHaveLength(hostCount) + + await vi.advanceTimersByTimeAsync(3_000) + await expect(loadPendingHostCredentialCleanupIds()).resolves.toHaveLength(hostCount) + }) }) diff --git a/mobile/src/transport/host-credential-cleanup.ts b/mobile/src/transport/host-credential-cleanup.ts index d64e37a98dc..c50c63bca33 100644 --- a/mobile/src/transport/host-credential-cleanup.ts +++ b/mobile/src/transport/host-credential-cleanup.ts @@ -1,7 +1,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const PENDING_STORAGE_KEY = 'orca:pending-host-credential-cleanups' const CLEANUP_CONFIRM_TIMEOUT_MS = 3_000 +export const HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS = 256 +export const HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS = 256 * 1024 +export const HOST_CREDENTIAL_CLEANUP_MAX_HOST_ID_CHARACTERS = 4_096 +export const HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES = 16 type DeleteHostCredential = (hostId: string) => Promise type CleanupAttemptResult = 'cleared' | 'pending' @@ -33,7 +38,11 @@ function notifyPendingListeners(): void { } function markUnrecordedPending(hostId: string): void { - if (unrecordedPendingIds.has(hostId)) { + if ( + unrecordedPendingIds.has(hostId) || + !isTrackableHostId(hostId) || + unrecordedPendingIds.size >= HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS + ) { return } unrecordedPendingIds.add(hostId) @@ -47,17 +56,55 @@ function clearUnrecordedPending(hostId: string): void { } function parsePendingIds(raw: string): string[] | null { + if (raw.length > HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS) { + return null + } try { - const parsed = JSON.parse(raw) as unknown + const parsed = parseMobileJsonTextWithinLimits(raw) if (!Array.isArray(parsed)) { return null } - return [...new Set(parsed.filter((value): value is string => typeof value === 'string'))] + const ids: string[] = [] + const seen = new Set() + for (const value of parsed) { + if (typeof value !== 'string') { + continue + } + if (!isTrackableHostId(value)) { + return null + } + if (!seen.has(value)) { + if (ids.length >= HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS) { + return null + } + seen.add(value) + ids.push(value) + } + } + return ids } catch { return null } } +function isTrackableHostId(hostId: string): boolean { + return hostId.length > 0 && hostId.length <= HOST_CREDENTIAL_CLEANUP_MAX_HOST_ID_CHARACTERS +} + +function serializePendingIds(ids: string[]): string { + if ( + ids.length > HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS || + ids.some((id) => !isTrackableHostId(id)) + ) { + throw new Error('pending host credential cleanup limit exceeded') + } + const serialized = JSON.stringify(ids) + if (serialized.length > HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS) { + throw new Error('pending host credential cleanup storage limit exceeded') + } + return serialized +} + function sameIdList(a: string[], b: string[]): boolean { return a.length === b.length && a.every((id, index) => id === b[index]) } @@ -103,7 +150,7 @@ async function mutatePendingIds(update: (ids: string[]) => string[]): Promise {}) @@ -142,6 +189,9 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia if (existing) { return existing } + if (inflightDeletes.size >= HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES) { + return Promise.reject(new Error('host credential cleanup concurrency limit reached')) + } const cleanup = Promise.resolve() .then(() => deleteCredential(hostId)) .finally(() => { @@ -154,6 +204,9 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia } async function recordCleanupIntent(hostId: string): Promise { + if (!isTrackableHostId(hostId)) { + return false + } try { await addPendingId(hostId) return true @@ -234,10 +287,10 @@ export async function retryPendingHostCredentialCleanups( deleteCredential: DeleteHostCredential ): Promise<{ clearedCount: number; remainingIds: string[]; storageUnreadable: boolean }> { const pending = await loadPendingCleanupState() - const outcomes = await Promise.all( - // Why: these ids are already durable (or a session-scoped fallback). Re-adding - // intent can race a late success and recreate a ghost row after deletion. - pending.ids.map((id) => confirmNativeCleanup(id, deleteCredential, CLEANUP_CONFIRM_TIMEOUT_MS)) + const outcomes = await retryCleanupIds( + pending.ids, + deleteCredential, + HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES ) const remaining = await loadPendingCleanupState() return { @@ -247,6 +300,29 @@ export async function retryPendingHostCredentialCleanups( } } +async function retryCleanupIds( + ids: string[], + deleteCredential: DeleteHostCredential, + concurrency: number +): Promise { + const outcomes: CleanupAttemptResult[] = [] + let nextIndex = 0 + const worker = async (): Promise => { + while (nextIndex < ids.length) { + const index = nextIndex++ + // Why: these ids already have recovery intent; re-adding can race a late + // success and recreate a ghost row after deletion. + outcomes[index] = await confirmNativeCleanup( + ids[index]!, + deleteCredential, + CLEANUP_CONFIRM_TIMEOUT_MS + ) + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())) + return outcomes +} + /** Test-only: drop module listeners/in-flight state between cases. */ export function resetHostCredentialCleanupForTests(): void { inflightDeletes.clear() diff --git a/mobile/src/transport/host-removal-lifecycle.test.ts b/mobile/src/transport/host-removal-lifecycle.test.ts index e398062b270..d19b73673e8 100644 --- a/mobile/src/transport/host-removal-lifecycle.test.ts +++ b/mobile/src/transport/host-removal-lifecycle.test.ts @@ -1,16 +1,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const removeHostMock = vi.hoisted(() => vi.fn()) +const { removeHostMock, deleteConnectionLogMock } = vi.hoisted(() => ({ + removeHostMock: vi.fn(), + deleteConnectionLogMock: vi.fn() +})) vi.mock('./host-store', () => ({ removeHost: (hostId: string) => removeHostMock(hostId) })) +vi.mock('./connection-log-buffer', () => ({ + connectionLogStore: { delete: deleteConnectionLogMock } +})) import { removeHostAndCloseClient } from './host-removal-lifecycle' describe('host removal lifecycle', () => { beforeEach(() => { removeHostMock.mockReset() + deleteConnectionLogMock.mockReset() }) it('closes the client only after metadata removal commits', async () => { @@ -28,6 +35,7 @@ describe('host removal lifecycle', () => { await removal expect(closeHostClient).toHaveBeenCalledWith('host-1') + expect(deleteConnectionLogMock).toHaveBeenCalledWith('host-1') }) it('keeps the client open when metadata removal fails', async () => { @@ -38,5 +46,18 @@ describe('host removal lifecycle', () => { 'storage unavailable' ) expect(closeHostClient).not.toHaveBeenCalled() + expect(deleteConnectionLogMock).not.toHaveBeenCalled() + }) + + it('forgets removed-host logs even when client teardown throws', async () => { + removeHostMock.mockResolvedValue(undefined) + const closeHostClient = vi.fn(() => { + throw new Error('close failed') + }) + + await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow( + 'close failed' + ) + expect(deleteConnectionLogMock).toHaveBeenCalledWith('host-1') }) }) diff --git a/mobile/src/transport/host-removal-lifecycle.ts b/mobile/src/transport/host-removal-lifecycle.ts index ba599614804..8f3148744f9 100644 --- a/mobile/src/transport/host-removal-lifecycle.ts +++ b/mobile/src/transport/host-removal-lifecycle.ts @@ -1,4 +1,5 @@ import { removeHost } from './host-store' +import { connectionLogStore } from './connection-log-buffer' export async function removeHostAndCloseClient( hostId: string, @@ -7,5 +8,9 @@ export async function removeHostAndCloseClient( // Why: closing before the metadata commit can strand a still-paired host on // storage failure; closing immediately after success prevents socket leaks. await removeHost(hostId) - closeHostClient(hostId) + try { + closeHostClient(hostId) + } finally { + connectionLogStore.delete(hostId) + } } diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 918e6e92f93..a9eb268d349 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -34,6 +34,8 @@ vi.mock('./host-credential-cleanup', () => ({ })) import { + HOST_TOKEN_CACHE_MAX_ENTRIES, + HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS, loadHosts, MobileRelayUpgradeHostRemovedError, removeHost, @@ -44,7 +46,18 @@ import { updateHostNameAndEndpoint, updateLastConnected } from './host-store' +import { + STORED_HOSTS_MAX_ENTRIES, + STORED_HOSTS_MAX_STORAGE_CHARACTERS +} from './mobile-host-list-storage' import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' +import { + MOBILE_HOST_ID_MAX_CHARACTERS, + MOBILE_HOST_NAME_MAX_CHARACTERS, + PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, + PAIRING_ENDPOINT_MAX_CHARACTERS, + PAIRING_PUBLIC_KEY_MAX_CHARACTERS +} from './types' const HOSTS_STORAGE_KEY = 'orca:hosts' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' @@ -222,6 +235,101 @@ describe('host-store list mutations', () => { expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false) }) + it('bounds cached keychain tokens without changing loaded hosts', async () => { + const storedHosts = Array.from({ length: HOST_TOKEN_CACHE_MAX_ENTRIES + 1 }, (_, index) => ({ + ...HOST_ONE, + id: `host-${index}`, + publicKeyB64: `key-${index}` + })) + storedHostsRaw = JSON.stringify(storedHosts) + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => `token-${key}`) + + await expect(loadHosts()).resolves.toHaveLength(storedHosts.length) + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(storedHosts.length) + + await expect(loadHosts()).resolves.toHaveLength(storedHosts.length) + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(storedHosts.length + 1) + }) + + it('does not retain an oversized token in the in-memory cache', async () => { + storedHostsRaw = JSON.stringify([HOST_ONE]) + secureStoreMock.getItemAsync.mockResolvedValue( + 'x'.repeat(HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS) + ) + + await expect(loadHosts()).resolves.toHaveLength(1) + await expect(loadHosts()).resolves.toHaveLength(1) + + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(2) + }) + + it('accepts the exact host count in order and rejects one more', async () => { + const exact = Array.from({ length: STORED_HOSTS_MAX_ENTRIES }, (_, index) => ({ + ...HOST_ONE, + id: `host-${index}`, + publicKeyB64: `key-${index}` + })) + storedHostsRaw = JSON.stringify(exact) + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => `token-${key}`) + + await expect(loadHosts()).resolves.toMatchObject( + exact.map((host) => ({ id: host.id, publicKeyB64: host.publicKeyB64 })) + ) + + storedHostsRaw = JSON.stringify([ + ...exact, + { ...HOST_ONE, id: 'host-overflow', publicKeyB64: 'key-overflow' } + ]) + await expect(loadHosts()).resolves.toEqual([]) + }) + + it('does not parse an oversized host-list payload', async () => { + storedHostsRaw = { + length: STORED_HOSTS_MAX_STORAGE_CHARACTERS + 1 + } as unknown as string + const parse = vi.spyOn(JSON, 'parse') + + await expect(loadHosts()).resolves.toEqual([]) + expect(parse).not.toHaveBeenCalled() + expect(secureStoreMock.getItemAsync).not.toHaveBeenCalled() + parse.mockRestore() + }) + + it('accepts exact host field limits and rejects one character more', async () => { + const exact = { + id: 'i'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS), + name: 'n'.repeat(MOBILE_HOST_NAME_MAX_CHARACTERS), + endpoint: 'e'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS), + deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), + publicKeyB64: 'p'.repeat(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), + lastConnected: 1 + } + + await expect(saveHost(exact)).resolves.toBeUndefined() + await expect(saveHost({ ...exact, name: `${exact.name}n` })).rejects.toThrow() + }) + + it('refuses a write that would exceed the stored host count', async () => { + storedHostsRaw = JSON.stringify( + Array.from({ length: STORED_HOSTS_MAX_ENTRIES }, (_, index) => ({ + ...HOST_ONE, + id: `host-${index}`, + publicKeyB64: `key-${index}` + })) + ) + + await expect( + saveHost({ + ...HOST_ONE, + id: 'host-overflow', + publicKeyB64: 'key-overflow', + deviceToken: 'token' + }) + ).rejects.toThrow(/storage limit/) + expect(asyncStorageMock.setItem).not.toHaveBeenCalledWith(HOSTS_STORAGE_KEY, expect.any(String)) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + it('refuses to resurrect a removed host during relay upgrade publication', async () => { storedHostsRaw = JSON.stringify([HOST_TWO]) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 61ed6742d12..7cffbe2d260 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -3,7 +3,7 @@ import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { HostProfileSchema, - StoredHostProfileSchema, + PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, type HostProfile, type StoredHostProfile } from './types' @@ -21,6 +21,10 @@ import { import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' +import { + parseMobileStoredHostList, + serializeMobileStoredHostList +} from './mobile-host-list-storage' const STORAGE_KEY = 'orca:hosts' // Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. @@ -43,10 +47,11 @@ function webTokenKey(hostId: string): string { async function readDeviceToken(hostId: string): Promise { // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. - if (Platform.OS === 'web') { - return AsyncStorage.getItem(webTokenKey(hostId)) - } - return SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + const token = + Platform.OS === 'web' + ? await AsyncStorage.getItem(webTokenKey(hostId)) + : await SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + return token !== null && token.length <= PAIRING_DEVICE_TOKEN_MAX_CHARACTERS ? token : null } async function writeDeviceToken(hostId: string, token: string): Promise { @@ -72,31 +77,30 @@ async function deleteHostCredentials(hostId: string): Promise { } // Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove. +export const HOST_TOKEN_CACHE_MAX_ENTRIES = 64 +export const HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS = 64 * 1024 const tokenCache = new Map() let inflightLoad: Promise | null = null // Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename). let hostListMutation: Promise = Promise.resolve() -function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { - if (!raw) { - return [] +function rememberHostToken(hostId: string, token: string, allowEviction: boolean): void { + if (hostId.length + token.length > HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS) { + tokenCache.delete(hostId) + return } - try { - const parsed = JSON.parse(raw) as unknown - if (!Array.isArray(parsed)) { - return null + if (tokenCache.has(hostId)) { + tokenCache.delete(hostId) + } else if (tokenCache.size >= HOST_TOKEN_CACHE_MAX_ENTRIES) { + if (!allowEviction) { + return + } + const oldestHostId = tokenCache.keys().next().value + if (oldestHostId !== undefined) { + tokenCache.delete(oldestHostId) } - return parsed.flatMap((item) => { - // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim. - if (item && typeof item === 'object' && 'deviceToken' in item) { - return [] - } - const result = StoredHostProfileSchema.safeParse(item) - return result.success ? [result.data] : [] - }) - } catch { - return null } + tokenCache.set(hostId, token) } export async function loadHosts(): Promise { @@ -114,7 +118,7 @@ export async function loadHosts(): Promise { async function doLoadHosts(): Promise { const raw = await AsyncStorage.getItem(STORAGE_KEY) - const storedHosts = parseStoredHosts(raw) + const storedHosts = parseMobileStoredHostList(raw) if (!storedHosts) { return [] } @@ -143,7 +147,7 @@ async function doLoadHosts(): Promise { continue } token = fetched - tokenCache.set(stored.id, token) + rememberHostToken(stored.id, token, false) } const overlay = overlays.get(stored.id) out.push({ @@ -176,7 +180,7 @@ export async function resolvePairingHostIdentity( async function readStoredHostsForMutation(): Promise { try { - const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) + const parsed = parseMobileStoredHostList(await AsyncStorage.getItem(STORAGE_KEY)) if (!parsed) { // Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write. throw new Error('host list storage unreadable') @@ -196,7 +200,7 @@ async function mutateStoredHosts( const mutation = hostListMutation.then(async () => { const current = await readStoredHostsForMutation() const next = update(current) - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + await AsyncStorage.setItem(STORAGE_KEY, serializeMobileStoredHostList(next)) }) hostListMutation = mutation.catch(() => {}) return mutation @@ -249,7 +253,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise }) // Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever. await writeDeviceToken(stored.id, validated.deviceToken) - tokenCache.set(stored.id, validated.deviceToken) + rememberHostToken(stored.id, validated.deviceToken, true) if (validated.endpoints) { await saveMobileRelayHostOverlay({ v: 2, diff --git a/mobile/src/transport/mobile-direct-rpc-outbound.test.ts b/mobile/src/transport/mobile-direct-rpc-outbound.test.ts new file mode 100644 index 00000000000..ab69ca0a55c --- /dev/null +++ b/mobile/src/transport/mobile-direct-rpc-outbound.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMobileDirectRpcOutbound } from './mobile-direct-rpc-outbound' +import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget' +import { MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS } from './mobile-outbound-socket-ledger' + +vi.mock('./e2ee', () => ({ + encrypt: (plaintext: string) => `encrypted:${plaintext}` +})) + +function socket() { + return { + OPEN: 1, + bufferedAmount: 0, + readyState: 1, + send: vi.fn() + } +} + +describe('mobile direct RPC outbound queue', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('parks in FIFO order while another socket owns aggregate native buffer credit', () => { + const budget = createMobileOutboundMemoryBudget({ + maxBufferedBytes: 100, + maxQueuedBytes: 1_000 + }) + const firstSocket = socket() + const secondSocket = socket() + firstSocket.bufferedAmount = 60 + secondSocket.bufferedAmount = 41 + const first = createMobileDirectRpcOutbound({ + socket: firstSocket, + isActive: () => true, + onOverflow: vi.fn(), + memoryBudget: budget + }) + const second = createMobileDirectRpcOutbound({ + socket: secondSocket, + isActive: () => true, + onOverflow: vi.fn(), + memoryBudget: budget + }) + + expect(first.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(true) + expect(first.enqueue('two', new Uint8Array(32), 'rpc-2')).toBe(true) + expect(firstSocket.send).not.toHaveBeenCalled() + expect(budget.evidence().queuedClaimCount).toBe(2) + + secondSocket.bufferedAmount = 0 + firstSocket.bufferedAmount = 0 + vi.advanceTimersByTime(25) + + expect(firstSocket.send.mock.calls.map(([frame]) => frame)).toEqual(['encrypted:one']) + first.acknowledge('rpc-1') + vi.advanceTimersByTime(25) + expect(firstSocket.send.mock.calls.map(([frame]) => frame)).toEqual([ + 'encrypted:one', + 'encrypted:two' + ]) + first.acknowledge('rpc-2') + expect(budget.evidence()).toMatchObject({ queuedBytes: 0, queuedClaimCount: 0 }) + first.dispose() + second.dispose() + expect(budget.evidence().bufferedSourceCount).toBe(2) + first.socketClosed() + second.socketClosed() + expect(budget.evidence().bufferedSourceCount).toBe(0) + }) + + it('fails the offending connection when aggregate JavaScript admission is exhausted', () => { + const budget = createMobileOutboundMemoryBudget({ + maxBufferedBytes: 1, + maxQueuedBytes: 10 + }) + const targetSocket = socket() + targetSocket.bufferedAmount = 2 + const onOverflow = vi.fn() + const outbound = createMobileDirectRpcOutbound({ + socket: targetSocket, + isActive: () => true, + onOverflow, + memoryBudget: budget + }) + + expect(outbound.enqueue('payload', new Uint8Array(32))).toBe(false) + + expect(onOverflow).toHaveBeenCalledOnce() + expect(targetSocket.send).not.toHaveBeenCalled() + expect(budget.evidence()).toMatchObject({ + bufferedSourceCount: 1, + queuedBytes: 0, + queuedClaimCount: 0 + }) + outbound.dispose() + expect(budget.evidence().bufferedSourceCount).toBe(1) + outbound.socketClosed() + expect(budget.evidence().bufferedSourceCount).toBe(0) + }) + + it('uses matching RPC responses as backpressure when bufferedAmount is unavailable', () => { + const budget = createMobileOutboundMemoryBudget({ + maxBufferedBytes: 100, + maxQueuedBytes: 1_000 + }) + const targetSocket = socket() + targetSocket.bufferedAmount = Number.NaN + const outbound = createMobileDirectRpcOutbound({ + socket: targetSocket, + isActive: () => true, + onOverflow: vi.fn(), + memoryBudget: budget + }) + + expect(outbound.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(true) + expect(outbound.enqueue('two', new Uint8Array(32), 'rpc-2')).toBe(true) + expect(targetSocket.send.mock.calls.map(([frame]) => frame)).toEqual(['encrypted:one']) + + outbound.acknowledge('rpc-1') + vi.advanceTimersByTime(25) + expect(targetSocket.send.mock.calls.map(([frame]) => frame)).toEqual([ + 'encrypted:one', + 'encrypted:two' + ]) + expect(budget.evidence()).toMatchObject({ inFlightBytes: 60, inFlightClaimCount: 1 }) + + outbound.socketClosed() + expect(budget.evidence()).toMatchObject({ bufferedBytes: 0, bufferedSourceCount: 0 }) + }) + + it('reports synchronous native send failure instead of marking a stream as sent', () => { + const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 100 }) + const targetSocket = socket() + targetSocket.send.mockImplementation(() => { + throw new Error('native send failed') + }) + const onOverflow = vi.fn() + const outbound = createMobileDirectRpcOutbound({ + socket: targetSocket, + isActive: () => true, + onOverflow, + memoryBudget: budget + }) + + expect(outbound.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(false) + expect(onOverflow).toHaveBeenCalledOnce() + expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 }) + outbound.socketClosed() + }) + + it('allows another socket after a retired socket never reports close', () => { + const budget = createMobileOutboundMemoryBudget({ + maxBufferedBytes: 100, + maxBufferedSources: 1 + }) + const first = createMobileDirectRpcOutbound({ + socket: socket(), + isActive: () => true, + onOverflow: vi.fn(), + memoryBudget: budget + }) + + first.dispose() + expect(budget.canRegisterBufferedAmount()).toBe(false) + vi.advanceTimersByTime(MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS) + expect(budget.canRegisterBufferedAmount()).toBe(true) + + const second = createMobileDirectRpcOutbound({ + socket: socket(), + isActive: () => true, + onOverflow: vi.fn(), + memoryBudget: budget + }) + second.socketClosed() + expect(budget.evidence().bufferedSourceCount).toBe(0) + }) +}) diff --git a/mobile/src/transport/mobile-direct-rpc-outbound.ts b/mobile/src/transport/mobile-direct-rpc-outbound.ts new file mode 100644 index 00000000000..20675917eda --- /dev/null +++ b/mobile/src/transport/mobile-direct-rpc-outbound.ts @@ -0,0 +1,126 @@ +import { createWsOutboundBackpressureQueue } from '../../../src/shared/ws-outbound-backpressure-queue' +import { getUtf8ByteLength } from '../../../src/shared/memory-safety/utf8-byte-limits' +import { encrypt } from './e2ee' +import { + processMobileOutboundMemoryBudget, + MOBILE_OUTBOUND_MAX_FRAME_BYTES, + type MobileOutboundMemoryBudget +} from './mobile-outbound-memory-budget' +import { createMobileOutboundSocketLedger } from './mobile-outbound-socket-ledger' + +type DirectRpcOutboundFrame = { + acknowledgementKey?: string + key: Uint8Array + plaintext: string +} + +type DirectRpcOutboundSocket = { + OPEN: number + bufferedAmount: number + readyState: number + send(frame: string): void +} + +export type MobileDirectRpcOutbound = { + acknowledge(key: string): void + acknowledgeAuthentication(): void + dispose(): void + enqueue(plaintext: string, key: Uint8Array, acknowledgementKey?: string): boolean + socketClosed(): void +} + +const AUTHENTICATION_ACKNOWLEDGEMENT_KEY = 'mobile-direct-authentication' + +export function createMobileDirectRpcOutbound(args: { + socket: DirectRpcOutboundSocket + isActive: () => boolean + onOverflow: () => void + memoryBudget?: MobileOutboundMemoryBudget +}): MobileDirectRpcOutbound { + const memoryBudget = args.memoryBudget ?? processMobileOutboundMemoryBudget + let stopped = false + let disposed = false + let overflowed = false + const socketLedger = createMobileOutboundSocketLedger({ + memoryBudget, + readBufferedAmount: () => args.socket.bufferedAmount + }) + const failOverflow = (): void => { + if (overflowed) { + return + } + overflowed = true + stopped = true + args.onOverflow() + } + const queue = createWsOutboundBackpressureQueue({ + send: (frame) => { + const bytes = encryptedTextFrameBytes(frame.plaintext) + const cancelClaim = socketLedger.claimSentBytes(bytes, frame.acknowledgementKey) + if (!cancelClaim) { + failOverflow() + return + } + try { + args.socket.send(encrypt(frame.plaintext, frame.key)) + } catch { + cancelClaim() + failOverflow() + } + }, + byteLengthOf: (frame) => encryptedTextFrameBytes(frame.plaintext), + getBufferedAmount: () => args.socket.bufferedAmount, + isWritable: () => args.isActive() && args.socket.readyState === args.socket.OPEN && !stopped, + canSend: (bytes) => socketLedger.canSend(bytes), + claimQueuedBytes: (bytes) => memoryBudget.claimQueuedBytes(bytes), + maxFrameBytes: MOBILE_OUTBOUND_MAX_FRAME_BYTES, + onOverflow: failOverflow + }) + + return { + acknowledge: (key) => socketLedger.acknowledge(key), + acknowledgeAuthentication: () => socketLedger.acknowledge(AUTHENTICATION_ACKNOWLEDGEMENT_KEY), + dispose(): void { + if (disposed) { + return + } + disposed = true + stopped = true + queue.dispose() + socketLedger.retire() + }, + enqueue(plaintext, key, acknowledgementKey): boolean { + if (stopped || !args.isActive() || args.socket.readyState !== args.socket.OPEN) { + return false + } + const accepted = queue.enqueue({ + plaintext, + key, + acknowledgementKey: + acknowledgementKey ?? + (isAuthenticationRequest(plaintext) ? AUTHENTICATION_ACKNOWLEDGEMENT_KEY : undefined) + }) + return accepted && !overflowed + }, + socketClosed(): void { + stopped = true + queue.dispose() + socketLedger.socketClosed() + } + } +} + +function encryptedTextFrameBytes(plaintext: string): number { + const encryptedBytes = getUtf8ByteLength(plaintext) + 40 + const wireBytes = Math.ceil(encryptedBytes / 3) * 4 + return Math.max(wireBytes, plaintext.length * 2) +} + +function isAuthenticationRequest(plaintext: string): boolean { + try { + const value = JSON.parse(plaintext) as { type?: unknown } + return value.type === 'e2ee_auth' + } catch { + return false + } +} diff --git a/mobile/src/transport/mobile-direct-rpc-sender.ts b/mobile/src/transport/mobile-direct-rpc-sender.ts new file mode 100644 index 00000000000..d2e9c4ca672 --- /dev/null +++ b/mobile/src/transport/mobile-direct-rpc-sender.ts @@ -0,0 +1,51 @@ +import type { MobileDirectRpcOutbound } from './mobile-direct-rpc-outbound' +import { stringifyMobileOutboundJson } from './mobile-outbound-json' +import type { ConnectionState } from './types' + +export function createMobileDirectRpcSender(args: { + getOutbound: () => MobileDirectRpcOutbound | null + getSharedKey: () => Uint8Array | null + getSocket: () => WebSocket | null + getState: () => ConnectionState + onSocketDesync: (socket: WebSocket) => void +}): (request: unknown) => boolean { + return (request): boolean => { + const socket = args.getSocket() + const sharedKey = args.getSharedKey() + const outbound = args.getOutbound() + if (socket && socket.readyState === WebSocket.OPEN && sharedKey && outbound) { + try { + return outbound.enqueue( + stringifyMobileOutboundJson(request), + sharedKey, + requestAcknowledgementKey(request) + ) + } catch (error) { + console.warn('[net] outbound request rejected', error) + return false + } + } + const state = args.getState() + console.log('[net] sendEncrypted FAILED — channel not ready', { + hasWs: !!socket, + readyState: socket?.readyState, + hasKey: !!sharedKey, + state + }) + if (state === 'connected' && socket && socket.readyState !== WebSocket.OPEN) { + console.log('[net] sendEncrypted detected ws desync — forcing reconnect', { + readyState: socket.readyState + }) + args.onSocketDesync(socket) + } + return false + } +} + +function requestAcknowledgementKey(request: unknown): string | undefined { + if (!request || typeof request !== 'object') { + return undefined + } + const id = (request as { id?: unknown }).id + return typeof id === 'string' ? id : undefined +} diff --git a/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts b/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts index 7e0628960e1..aea6f3d7a55 100644 --- a/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts +++ b/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts @@ -6,7 +6,13 @@ vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length).fill(9) })) -import { decrypt, decryptBytes, deriveSharedKey } from './e2ee' +import { + MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS, + decrypt, + decryptBytes, + deriveSharedKey, + publicKeyFromBase64 +} from './e2ee' describe('mobile legacy E2EE fixtures', () => { it('matches the captured desktop key and text/binary frames', () => { @@ -19,6 +25,17 @@ describe('mobile legacy E2EE fixtures', () => { expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext) expect(decryptBytes(fromHex(fixture.binaryFrameHex), shared)).toEqual(fixture.binaryPlaintext) }) + + it('rejects oversized base64 before calling the decoder', () => { + const decode = vi.spyOn(globalThis, 'atob') + const oversizedFrame = { + length: MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS + 1 + } as unknown as string + + expect(() => publicKeyFromBase64('A'.repeat(45))).toThrow(/safe size/) + expect(() => decrypt(oversizedFrame, new Uint8Array(32))).toThrow(/safe size/) + expect(decode).not.toHaveBeenCalled() + }) }) function hex(bytes: Uint8Array): string { diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts index 6cff0ef3523..3cccc28260c 100644 --- a/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts +++ b/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts @@ -12,8 +12,12 @@ vi.mock('expo-crypto', () => ({ })) import { deriveSharedKey } from './e2ee' -import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' +import { + MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS, + MobileE2EEV2ClientSession +} from './mobile-e2ee-v2-client-session' import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' +import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue' const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) @@ -82,4 +86,19 @@ describe('mobile E2EE v2 client session', () => { expect(session.openText(encoded)).toBe('authenticated') expect(session.openText(encoded)).toBeNull() }) + + it('rejects oversized text and binary frames before materialization', () => { + const { session } = setup() + const decode = vi.spyOn(globalThis, 'atob') + const oversizedText = { + length: MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS + 1 + } as unknown as string + const oversizedBinary = { + byteLength: MOBILE_INBOUND_MAX_FRAME_BYTES + 1 + } as unknown as Uint8Array + + expect(session.openText(oversizedText)).toBeNull() + expect(session.openBinary(oversizedBinary)).toBeNull() + expect(decode).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.ts index 1cce2946860..80246607a8c 100644 --- a/mobile/src/transport/mobile-e2ee-v2-client-session.ts +++ b/mobile/src/transport/mobile-e2ee-v2-client-session.ts @@ -11,6 +11,10 @@ import { } from '../../../src/shared/mobile-e2ee-v2-framing' import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee' import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' +import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue' + +export const MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS = + Math.ceil(MOBILE_INBOUND_MAX_FRAME_BYTES / 3) * 4 export class MobileE2EEV2ClientSession { readonly hello: MobileE2EEV2Hello @@ -91,6 +95,9 @@ export class MobileE2EEV2ClientSession { } openBinary(frame: Uint8Array): Uint8Array | null { + if (frame.byteLength > MOBILE_INBOUND_MAX_FRAME_BYTES) { + return null + } return this.open(frame, 'binary') } @@ -146,8 +153,14 @@ function encodeBase64(bytes: Uint8Array): string { } function decodeCanonicalBase64(value: string): Uint8Array | null { + if (value.length > MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS) { + return null + } try { const binary = atob(value) + if (binary.length > MOBILE_INBOUND_MAX_FRAME_BYTES) { + return null + } const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) return encodeBase64(bytes) === value ? bytes : null } catch { diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts index a7aae9e6d0f..064b117e168 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts @@ -22,11 +22,16 @@ import { MobileE2EEV2PhysicalChannel, type MobileE2EEV2Socket } from './mobile-e2ee-v2-physical-channel' +import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget' +import { MOBILE_OUTBOUND_MAX_JSON_BYTES } from './mobile-outbound-json' const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) -function setup(decodeBinary: (raw: unknown) => Promise) { +function setup( + decodeBinary: (raw: unknown) => Promise, + deviceToken = 'valid-token' +) { const session = MobileE2EEV2ClientSession.create({ desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), transport: 'relay', @@ -44,15 +49,17 @@ function setup(decodeBinary: (raw: unknown) => Promise) { const events: string[] = [] const onAuthenticated = vi.fn(() => events.push('authenticated')) const onError = vi.fn() + const outboundMemoryBudget = createMobileOutboundMemoryBudget() const channel = new MobileE2EEV2PhysicalChannel({ session, socket, - deviceToken: 'valid-token', + deviceToken, decodeBinary, onAuthenticated, onText: (plaintext) => events.push(`text:${plaintext}`), onBinary: (plaintext) => events.push(`binary:${plaintext[0]}`), - onError + onError, + outboundMemoryBudget }) channel.start() @@ -72,7 +79,18 @@ function setup(decodeBinary: (raw: unknown) => Promise) { clientNonce: handshake.clientNonce, desktopNonce: handshake.desktopNonce }) - return { channel, session, socket, sent, events, onAuthenticated, onError, ready, schedule } + return { + channel, + session, + socket, + sent, + events, + onAuthenticated, + onError, + outboundMemoryBudget, + ready, + schedule + } } function serverFrame( @@ -122,6 +140,17 @@ describe('mobile E2EE v2 physical channel', () => { expect(ctx.onError).not.toHaveBeenCalled() }) + it('rejects ready JSON above the nesting cap before object materialization', async () => { + const ctx = setup(async () => null) + + await ctx.channel.handleMessage(`${'['.repeat(129)}0${']'.repeat(129)}`) + + expect(ctx.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Invalid E2EE v2 ready JSON' }) + ) + expect(ctx.sent).toHaveLength(1) + }) + it('classifies the encrypted desktop device-token rejection as global auth failure', async () => { const ctx = setup(async () => null) await ctx.channel.handleMessage(JSON.stringify(ctx.ready)) @@ -140,6 +169,18 @@ describe('mobile E2EE v2 physical channel', () => { expect(ctx.onAuthenticated).not.toHaveBeenCalled() }) + it('rejects oversized authentication JSON before sealing or sending it', async () => { + const ctx = setup(async () => null, 'x'.repeat(MOBILE_OUTBOUND_MAX_JSON_BYTES)) + + await ctx.channel.handleMessage(JSON.stringify(ctx.ready)) + + expect(ctx.sent).toHaveLength(1) + expect(ctx.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Mobile outbound JSON exceeds') }) + ) + ctx.channel.socketClosed() + }) + it('serializes delayed binary conversion before a later text counter', async () => { let releaseBinary!: (bytes: Uint8Array) => void const pendingBinary = new Promise((resolve) => (releaseBinary = resolve)) @@ -160,6 +201,27 @@ describe('mobile E2EE v2 physical channel', () => { expect(ctx.onError).not.toHaveBeenCalled() }) + it('fails closed when delayed inbound conversion lets frames exceed the backlog bound', async () => { + let releaseBinary!: (bytes: Uint8Array) => void + const pendingBinary = new Promise((resolve) => (releaseBinary = resolve)) + const ctx = setup(async () => pendingBinary) + await authenticate(ctx) + const binary = serverFrame(new Uint8Array([7]), 'binary', 1n, ctx.schedule) + const pending = Array.from({ length: 65 }, () => + ctx.channel.handleMessage({ delayedBlob: true }) + ) + + await Promise.resolve() + try { + expect(ctx.onError).toHaveBeenCalledOnce() + expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 inbound buffer overflow') + } finally { + ctx.channel.dispose() + releaseBinary(binary) + await Promise.all(pending) + } + }) + it('queues outbound text and binary in one counter order', async () => { const ctx = setup(async () => null) await authenticate(ctx) @@ -194,15 +256,61 @@ describe('mobile E2EE v2 physical channel', () => { ).toEqual(new Uint8Array([2])) }) + it('releases sent-frame reservations only after the matching response arrives', async () => { + const ctx = setup(async () => null) + await authenticate(ctx) + expect(ctx.outboundMemoryBudget.evidence().inFlightBytes).toBe(0) + + expect(ctx.channel.sendText(JSON.stringify({ id: 'rpc-1', method: 'status.get' }))).toBe(true) + expect(ctx.outboundMemoryBudget.evidence().inFlightClaimCount).toBe(1) + + const response = serverFrame( + new TextEncoder().encode(JSON.stringify({ id: 'rpc-1', ok: true, result: {} })), + 'text', + 1n, + ctx.schedule + ) + await ctx.channel.handleMessage(Buffer.from(response).toString('base64')) + + expect(ctx.outboundMemoryBudget.evidence()).toMatchObject({ + inFlightBytes: 0, + inFlightClaimCount: 0 + }) + ctx.channel.socketClosed() + }) + + it('reports synchronous native send failure to relay callers', async () => { + const ctx = setup(async () => null) + await authenticate(ctx) + ctx.socket.send = () => { + throw new Error('native send failed') + } + + expect(ctx.channel.sendText(JSON.stringify({ id: 'rpc-1' }))).toBe(false) + expect(ctx.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'E2EE v2 outbound buffer overflow' }) + ) + expect(ctx.outboundMemoryBudget.evidence()).toMatchObject({ + inFlightBytes: 0, + inFlightClaimCount: 0 + }) + ctx.channel.socketClosed() + }) + it('bounds the unified outbound queue and reports a wedged link', async () => { const ctx = setup(async () => null) await authenticate(ctx) ctx.socket.bufferedAmount = 9 * 1024 * 1024 const megabyte = new Uint8Array(1024 * 1024) for (let index = 0; index < 65; index++) { - expect(ctx.channel.sendBinary(megabyte)).toBe(true) + ctx.channel.sendBinary(megabyte) } expect(ctx.onError).toHaveBeenCalledOnce() expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 outbound buffer overflow') + expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(1) + ctx.channel.dispose() + expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(1) + ctx.channel.socketClosed() + expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(0) }) }) diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts index ec3b8088915..854832197f7 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts @@ -2,10 +2,28 @@ import { createWsOutboundBackpressureQueue, type WsOutboundBackpressureQueue } from '../../../src/shared/ws-outbound-backpressure-queue' +import { getUtf8ByteLength } from '../../../src/shared/memory-safety/utf8-byte-limits' import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' +import { + createMobileInboundFrameQueue, + MOBILE_INBOUND_MAX_FRAME_BYTES, + type MobileInboundFrameQueue +} from './mobile-inbound-frame-queue' +import { + processMobileOutboundMemoryBudget, + MOBILE_OUTBOUND_MAX_FRAME_BYTES, + type MobileOutboundMemoryBudget +} from './mobile-outbound-memory-budget' +import { stringifyMobileOutboundJson } from './mobile-outbound-json' +import { createMobileOutboundSocketLedger } from './mobile-outbound-socket-ledger' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready' -type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array } +type OutboundItem = + | { kind: 'text'; plaintext: string; acknowledgementKey?: string } + | { kind: 'binary'; plaintext: Uint8Array } +const E2EE_V2_FRAME_OVERHEAD_BYTES = 82 +const AUTHENTICATION_ACKNOWLEDGEMENT_KEY = 'mobile-e2ee-v2-authentication' export class MobileE2EEAuthenticationError extends Error { constructor() { @@ -23,8 +41,10 @@ export type MobileE2EEV2Socket = { export class MobileE2EEV2PhysicalChannel { private state: ChannelState = 'awaiting-ready' private generation = 0 - private inboundChain: Promise = Promise.resolve() + private readonly inboundQueue: MobileInboundFrameQueue private readonly outboundQueue: WsOutboundBackpressureQueue + private readonly outboundSocketLedger + private outboundOverflowed = false constructor( private readonly args: { @@ -36,25 +56,50 @@ export class MobileE2EEV2PhysicalChannel { onText: (plaintext: string) => void onBinary: (plaintext: Uint8Array) => void onError: (error: Error) => void + outboundMemoryBudget?: MobileOutboundMemoryBudget } ) { + this.inboundQueue = createMobileInboundFrameQueue({ + process: (raw) => this.processMessage(raw, this.generation), + onError: args.onError, + overflowMessage: 'E2EE v2 inbound buffer overflow', + frameTooLargeMessage: 'E2EE v2 inbound frame too large' + }) + const outboundMemoryBudget = args.outboundMemoryBudget ?? processMobileOutboundMemoryBudget + this.outboundSocketLedger = createMobileOutboundSocketLedger({ + memoryBudget: outboundMemoryBudget, + readBufferedAmount: () => args.socket.bufferedAmount + }) this.outboundQueue = createWsOutboundBackpressureQueue({ // Why: encryption happens only when an admitted item reaches the wire, // so a bounded-queue rejection cannot burn an ordered v2 counter. send: (item) => { - args.socket.send( - item.kind === 'text' - ? args.session.sealText(item.plaintext) - : args.session.sealBinary(item.plaintext) + const cancelClaim = this.outboundSocketLedger.claimSentBytes( + outboundItemRetainedBytes(item), + item.kind === 'text' ? item.acknowledgementKey : undefined ) + if (!cancelClaim) { + this.failOutboundOverflow() + return + } + try { + args.socket.send( + item.kind === 'text' + ? args.session.sealText(item.plaintext) + : args.session.sealBinary(item.plaintext) + ) + } catch { + cancelClaim() + this.failOutboundOverflow() + } }, - byteLengthOf: (item) => - (item.kind === 'text' - ? new TextEncoder().encode(item.plaintext).length - : item.plaintext.length) + 82, + byteLengthOf: outboundItemRetainedBytes, getBufferedAmount: () => args.socket.bufferedAmount, isWritable: () => args.socket.readyState === args.socket.OPEN, - onOverflow: () => args.onError(new Error('E2EE v2 outbound buffer overflow')) + canSend: (bytes) => this.outboundSocketLedger.canSend(bytes), + claimQueuedBytes: (bytes) => outboundMemoryBudget.claimQueuedBytes(bytes), + maxFrameBytes: MOBILE_OUTBOUND_MAX_FRAME_BYTES, + onOverflow: () => this.failOutboundOverflow() }) } @@ -63,19 +108,15 @@ export class MobileE2EEV2PhysicalChannel { } handleMessage(raw: unknown): Promise { - const generation = this.generation - this.inboundChain = this.inboundChain - .then(() => this.processMessage(raw, generation)) - .catch((error: unknown) => { - if (generation === this.generation) { - this.args.onError(error instanceof Error ? error : new Error(String(error))) - } - }) - return this.inboundChain + return this.inboundQueue.enqueue(raw) } sendText(plaintext: string): boolean { - return this.enqueueReady({ kind: 'text', plaintext }) + return this.enqueueReady({ + kind: 'text', + plaintext, + acknowledgementKey: outboundTextAcknowledgementKey(plaintext) + }) } sendBinary(plaintext: Uint8Array): boolean { @@ -84,7 +125,14 @@ export class MobileE2EEV2PhysicalChannel { dispose(): void { this.generation++ + this.inboundQueue.dispose() this.outboundQueue.dispose() + this.outboundSocketLedger.retire() + } + + socketClosed(): void { + this.dispose() + this.outboundSocketLedger.socketClosed() } private async processMessage(raw: unknown, generation: number): Promise { @@ -104,6 +152,7 @@ export class MobileE2EEV2PhysicalChannel { return } if (this.state === 'awaiting-authenticated') { + this.outboundSocketLedger.acknowledge(AUTHENTICATION_ACKNOWLEDGEMENT_KEY) if (typeof plaintext === 'string' && isAuthenticationRejection(plaintext)) { throw new MobileE2EEAuthenticationError() } @@ -113,6 +162,10 @@ export class MobileE2EEV2PhysicalChannel { this.state = 'ready' this.args.onAuthenticated() } else if (typeof plaintext === 'string') { + const acknowledgementKey = outboundTextAcknowledgementKey(plaintext) + if (acknowledgementKey) { + this.outboundSocketLedger.acknowledge(acknowledgementKey) + } this.args.onText(plaintext) } else { this.args.onBinary(plaintext) @@ -125,7 +178,7 @@ export class MobileE2EEV2PhysicalChannel { } let ready: unknown try { - ready = JSON.parse(raw) + ready = parseMobileJsonTextWithinLimits(raw) } catch { throw new Error('Invalid E2EE v2 ready JSON') } @@ -135,7 +188,8 @@ export class MobileE2EEV2PhysicalChannel { this.state = 'awaiting-authenticated' this.outboundQueue.enqueue({ kind: 'text', - plaintext: JSON.stringify({ + acknowledgementKey: AUTHENTICATION_ACKNOWLEDGEMENT_KEY, + plaintext: stringifyMobileOutboundJson({ type: 'e2ee_auth', v: 2, transcriptHashB64: this.args.session.transcriptHashB64, @@ -149,12 +203,15 @@ export class MobileE2EEV2PhysicalChannel { if (!bytes || generation !== this.generation) { return null } + if (bytes.byteLength > MOBILE_INBOUND_MAX_FRAME_BYTES) { + throw new Error('E2EE v2 inbound frame too large') + } return this.args.session.openBinary(bytes) } private isAuthenticated(plaintext: string): boolean { try { - const message = JSON.parse(plaintext) as Record + const message = parseMobileJsonTextWithinLimits>(plaintext) return ( Object.keys(message).sort().join(',') === 'transcriptHashB64,type,v' && message.type === 'e2ee_authenticated' && @@ -170,16 +227,41 @@ export class MobileE2EEV2PhysicalChannel { if (this.state !== 'ready') { return false } - this.outboundQueue.enqueue(item) - return true + return this.outboundQueue.enqueue(item) && !this.outboundOverflowed + } + + private failOutboundOverflow(): void { + if (this.outboundOverflowed) { + return + } + this.outboundOverflowed = true + this.args.onError(new Error('E2EE v2 outbound buffer overflow')) } } function isAuthenticationRejection(plaintext: string): boolean { try { - const message = JSON.parse(plaintext) as Record + const message = parseMobileJsonTextWithinLimits>(plaintext) return message.type === 'e2ee_error' } catch { return false } } + +function outboundItemRetainedBytes(item: OutboundItem): number { + if (item.kind === 'binary') { + return item.plaintext.byteLength + E2EE_V2_FRAME_OVERHEAD_BYTES + } + const encryptedBytes = getUtf8ByteLength(item.plaintext) + E2EE_V2_FRAME_OVERHEAD_BYTES + const wireBytes = Math.ceil(encryptedBytes / 3) * 4 + return Math.max(wireBytes, item.plaintext.length * 2) +} + +function outboundTextAcknowledgementKey(plaintext: string): string | undefined { + try { + const value = parseMobileJsonTextWithinLimits<{ id?: unknown }>(plaintext) + return typeof value.id === 'string' ? value.id : undefined + } catch { + return undefined + } +} diff --git a/mobile/src/transport/mobile-host-list-storage.ts b/mobile/src/transport/mobile-host-list-storage.ts new file mode 100644 index 00000000000..76ccd6fddd2 --- /dev/null +++ b/mobile/src/transport/mobile-host-list-storage.ts @@ -0,0 +1,43 @@ +import { StoredHostProfileSchema, type StoredHostProfile } from './types' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' + +export const STORED_HOSTS_MAX_ENTRIES = 256 +export const STORED_HOSTS_MAX_STORAGE_CHARACTERS = 8 * 1024 * 1024 + +export function parseMobileStoredHostList(raw: string | null): StoredHostProfile[] | null { + if (!raw) { + return [] + } + if (raw.length > STORED_HOSTS_MAX_STORAGE_CHARACTERS) { + return null + } + try { + const parsed = parseMobileJsonTextWithinLimits(raw) + if (!Array.isArray(parsed) || parsed.length > STORED_HOSTS_MAX_ENTRIES) { + return null + } + return parsed.flatMap((item) => { + // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; dropping + // them keeps bearer material out of the metadata migration path. + if (item && typeof item === 'object' && 'deviceToken' in item) { + return [] + } + const result = StoredHostProfileSchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return null + } +} + +export function serializeMobileStoredHostList(hosts: StoredHostProfile[]): string { + if (hosts.length > STORED_HOSTS_MAX_ENTRIES) { + throw new Error('host list storage limit exceeded') + } + const validated = hosts.map((host) => StoredHostProfileSchema.parse(host)) + const serialized = JSON.stringify(validated) + if (serialized.length > STORED_HOSTS_MAX_STORAGE_CHARACTERS) { + throw new Error('host list storage limit exceeded') + } + return serialized +} diff --git a/mobile/src/transport/mobile-inbound-frame-queue-memory.test.ts b/mobile/src/transport/mobile-inbound-frame-queue-memory.test.ts new file mode 100644 index 00000000000..fe722ae8359 --- /dev/null +++ b/mobile/src/transport/mobile-inbound-frame-queue-memory.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMobileInboundFrameQueue } from './mobile-inbound-frame-queue' +import { createMobileInboundMemoryLedger } from './mobile-inbound-memory-ledger' + +function expectEmpty(ledger: ReturnType): void { + expect(ledger.evidence()).toMatchObject({ claimCount: 0, retainedBytes: 0 }) +} + +describe('mobile inbound frame queue aggregate memory', () => { + it('releases a claim after synchronous processing succeeds', async () => { + const ledger = createMobileInboundMemoryLedger(10) + const queue = createMobileInboundFrameQueue({ + process: vi.fn(), + onError: vi.fn(), + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large', + memoryLedger: ledger + }) + + await queue.enqueue(new Uint8Array(3)) + + expectEmpty(ledger) + }) + + it('releases a claim after synchronous processing throws', async () => { + const ledger = createMobileInboundMemoryLedger(10) + const onError = vi.fn() + const queue = createMobileInboundFrameQueue({ + process: () => { + throw new Error('processing failed') + }, + onError, + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large', + memoryLedger: ledger + }) + + await queue.enqueue(new Uint8Array(3)) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'processing failed' })) + expectEmpty(ledger) + }) + + it('rejects a frame when another physical queue owns the remaining process credit', async () => { + const ledger = createMobileInboundMemoryLedger(5) + let releaseFirst!: () => void + const first = createMobileInboundFrameQueue({ + process: () => new Promise((resolve) => (releaseFirst = resolve)), + onError: vi.fn(), + overflowMessage: 'first overflow', + frameTooLargeMessage: 'too large', + memoryLedger: ledger + }) + const secondProcess = vi.fn() + const secondError = vi.fn() + const second = createMobileInboundFrameQueue({ + process: secondProcess, + onError: secondError, + overflowMessage: 'aggregate overflow', + frameTooLargeMessage: 'too large', + memoryLedger: ledger + }) + + const firstPending = first.enqueue(new Uint8Array(3)) + await second.enqueue(new Uint8Array(3)) + + expect(secondProcess).not.toHaveBeenCalled() + expect(secondError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'aggregate overflow' }) + ) + expect(ledger.evidence()).toMatchObject({ claimCount: 1, retainedBytes: 3 }) + releaseFirst() + await firstPending + expectEmpty(ledger) + }) + + it('releases current and queued claims after processing rejects', async () => { + const ledger = createMobileInboundMemoryLedger(10) + let rejectCurrent!: (error: Error) => void + const onError = vi.fn() + const queue = createMobileInboundFrameQueue({ + process: () => new Promise((_resolve, reject) => (rejectCurrent = reject)), + onError, + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large', + memoryLedger: ledger + }) + const first = queue.enqueue(new Uint8Array(2)) + const second = queue.enqueue(new Uint8Array(3)) + expect(ledger.evidence()).toMatchObject({ claimCount: 2, retainedBytes: 5 }) + + rejectCurrent(new Error('processing failed')) + await Promise.all([first, second]) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'processing failed' })) + expectEmpty(ledger) + }) + + it('releases queued claims on dispose and the active claim when processing settles', async () => { + const ledger = createMobileInboundMemoryLedger(10) + let releaseCurrent!: () => void + const queue = createMobileInboundFrameQueue({ + process: () => new Promise((resolve) => (releaseCurrent = resolve)), + onError: vi.fn(), + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large', + memoryLedger: ledger + }) + const first = queue.enqueue(new Uint8Array(2)) + const second = queue.enqueue(new Uint8Array(3)) + + queue.dispose() + expect(ledger.evidence()).toMatchObject({ claimCount: 1, retainedBytes: 2 }) + releaseCurrent() + await Promise.all([first, second]) + expectEmpty(ledger) + }) + + it('releases every claim after per-queue overflow', async () => { + const ledger = createMobileInboundMemoryLedger(10) + let releaseCurrent!: () => void + const queue = createMobileInboundFrameQueue({ + process: () => new Promise((resolve) => (releaseCurrent = resolve)), + onError: vi.fn(), + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large', + maxBufferedBytes: 4, + memoryLedger: ledger + }) + const first = queue.enqueue(new Uint8Array(3)) + const rejected = queue.enqueue(new Uint8Array(2)) + + expect(ledger.evidence()).toMatchObject({ claimCount: 1, retainedBytes: 3 }) + releaseCurrent() + await Promise.all([first, rejected]) + expectEmpty(ledger) + }) + + it('does not claim process memory for an individually oversized frame', async () => { + const ledger = createMobileInboundMemoryLedger(10) + const queue = createMobileInboundFrameQueue({ + process: vi.fn(), + onError: vi.fn(), + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large', + maxFrameBytes: 2, + memoryLedger: ledger + }) + + await queue.enqueue(new Uint8Array(3)) + + expectEmpty(ledger) + }) +}) diff --git a/mobile/src/transport/mobile-inbound-frame-queue.test.ts b/mobile/src/transport/mobile-inbound-frame-queue.test.ts new file mode 100644 index 00000000000..1a006a9d094 --- /dev/null +++ b/mobile/src/transport/mobile-inbound-frame-queue.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMobileInboundFrameQueue } from './mobile-inbound-frame-queue' + +describe('mobile inbound frame queue', () => { + it('serializes async processing without losing frame order', async () => { + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + const processed: string[] = [] + const queue = createMobileInboundFrameQueue({ + process: async (raw) => { + if (raw === 'first') { + await gate + } + processed.push(String(raw)) + }, + onError: vi.fn(), + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large' + }) + + const first = queue.enqueue('first') + const second = queue.enqueue('second') + expect(processed).toEqual([]) + release() + await Promise.all([first, second]) + expect(processed).toEqual(['first', 'second']) + }) + + it('does not retain processed frame slots while a steady stream keeps the queue busy', async () => { + let releaseCurrent!: () => void + const queue = createMobileInboundFrameQueue({ + process: () => new Promise((resolve) => (releaseCurrent = resolve)), + onError: vi.fn(), + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large' + }) + let current = queue.enqueue({ byteLength: 1, id: 0 }) + let next = queue.enqueue({ byteLength: 1, id: 1 }) + + for (let id = 2; id < 256; id += 1) { + releaseCurrent() + await current + current = next + next = queue.enqueue({ byteLength: 1, id }) + expect(queue.evidence().storageSlots).toBeLessThanOrEqual(64) + } + + releaseCurrent() + await current + releaseCurrent() + await next + expect(queue.evidence()).toEqual({ retainedBytes: 0, retainedFrames: 0, storageSlots: 0 }) + }) + + it('drops and settles the backlog when retained frame count reaches its cap', async () => { + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + const onError = vi.fn() + const queue = createMobileInboundFrameQueue({ + process: async () => gate, + onError, + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large', + maxBufferedFrames: 2, + maxBufferedBytes: 100, + maxFrameBytes: 100 + }) + + const pending = [ + queue.enqueue(new Uint8Array(1)), + queue.enqueue(new Uint8Array(1)), + queue.enqueue(new Uint8Array(1)) + ] + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'overflow' })) + release() + await Promise.all(pending) + }) + + it('fails closed when aggregate retained bytes exceed their cap', async () => { + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + const onError = vi.fn() + const queue = createMobileInboundFrameQueue({ + process: async () => gate, + onError, + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large', + maxBufferedFrames: 10, + maxBufferedBytes: 5, + maxFrameBytes: 4 + }) + + const first = queue.enqueue(new Uint8Array(3)) + const second = queue.enqueue(new Uint8Array(3)) + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'overflow' })) + release() + await Promise.all([first, second]) + }) + + it('rejects a single known oversized frame without invoking its processor', async () => { + const process = vi.fn() + const onError = vi.fn() + const queue = createMobileInboundFrameQueue({ + process, + onError, + overflowMessage: 'overflow', + frameTooLargeMessage: 'too large', + maxFrameBytes: 4 + }) + + await queue.enqueue(new Uint8Array(5)) + + expect(process).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'too large' })) + }) +}) diff --git a/mobile/src/transport/mobile-inbound-frame-queue.ts b/mobile/src/transport/mobile-inbound-frame-queue.ts new file mode 100644 index 00000000000..4396d0f4832 --- /dev/null +++ b/mobile/src/transport/mobile-inbound-frame-queue.ts @@ -0,0 +1,209 @@ +import { + processMobileInboundMemoryLedger, + type MobileInboundMemoryLedger +} from './mobile-inbound-memory-ledger' + +// Why: a valid 10 MiB image preview expands twice through nested base64 framing to ~36 MiB of JS text. +export const MOBILE_INBOUND_MAX_FRAME_BYTES = 64 * 1024 * 1024 +// Why: two valid large previews may overlap, but a stalled consumer cannot retain a third. +export const MOBILE_INBOUND_MAX_BUFFERED_BYTES = 96 * 1024 * 1024 +// Why: tiny-frame floods need a count bound independent of their serialized size. +export const MOBILE_INBOUND_MAX_BUFFERED_FRAMES = 64 + +type PendingFrame = { + raw: unknown + releaseMemory: () => void + retainedBytes: number + settle: () => void +} + +export type MobileInboundFrameQueueEvidence = { + retainedBytes: number + retainedFrames: number + storageSlots: number +} + +export type MobileInboundFrameQueue = { + enqueue(raw: unknown): Promise + dispose(): void + evidence(): MobileInboundFrameQueueEvidence +} + +const QUEUE_COMPACTION_HEAD_THRESHOLD = 64 + +export function createMobileInboundFrameQueue(options: { + process: (raw: unknown) => Promise | void + onError: (error: Error) => void + overflowMessage: string + frameTooLargeMessage: string + maxFrameBytes?: number + maxBufferedBytes?: number + maxBufferedFrames?: number + memoryLedger?: MobileInboundMemoryLedger +}): MobileInboundFrameQueue { + const maxFrameBytes = options.maxFrameBytes ?? MOBILE_INBOUND_MAX_FRAME_BYTES + const maxBufferedBytes = options.maxBufferedBytes ?? MOBILE_INBOUND_MAX_BUFFERED_BYTES + const maxBufferedFrames = options.maxBufferedFrames ?? MOBILE_INBOUND_MAX_BUFFERED_FRAMES + const memoryLedger = options.memoryLedger ?? processMobileInboundMemoryLedger + const queue: Array = [] + let queueHead = 0 + let retainedBytes = 0 + let retainedFrames = 0 + let draining = false + let stopped = false + + const release = (frame: PendingFrame): void => { + retainedBytes -= frame.retainedBytes + retainedFrames -= 1 + frame.releaseMemory() + frame.settle() + } + + const dropQueued = (): void => { + while (queueHead < queue.length) { + release(queue[queueHead++]!) + } + queue.length = 0 + queueHead = 0 + } + + const fail = (error: Error): void => { + if (stopped) { + return + } + stopped = true + dropQueued() + options.onError(error) + } + + const finishDrain = (): void => { + if (queueHead === queue.length) { + queue.length = 0 + queueHead = 0 + } + draining = false + } + + const continueDrain = (): void => { + while (!stopped && queueHead < queue.length) { + const frame = queue[queueHead++]! + queue[queueHead - 1] = undefined + if (queueHead >= QUEUE_COMPACTION_HEAD_THRESHOLD) { + queue.splice(0, queueHead) + queueHead = 0 + } + let processing: Promise | void + try { + processing = options.process(frame.raw) + } catch (error) { + release(frame) + fail(error instanceof Error ? error : new Error(String(error))) + finishDrain() + return + } + if (processing) { + void processing.then( + () => { + release(frame) + if (stopped) { + finishDrain() + return + } + continueDrain() + }, + (error: unknown) => { + release(frame) + fail(error instanceof Error ? error : new Error(String(error))) + finishDrain() + } + ) + return + } + release(frame) + } + finishDrain() + } + + const drain = (): void => { + if (draining || stopped) { + return + } + draining = true + continueDrain() + } + + return { + enqueue(raw): Promise { + if (stopped) { + return Promise.resolve() + } + const frameBytes = mobileInboundFrameRetainedBytes(raw, maxFrameBytes) + if (frameBytes > maxFrameBytes) { + fail(new Error(options.frameTooLargeMessage)) + return Promise.resolve() + } + if (retainedFrames >= maxBufferedFrames || retainedBytes + frameBytes > maxBufferedBytes) { + fail(new Error(options.overflowMessage)) + return Promise.resolve() + } + const releaseMemory = memoryLedger.claim(frameBytes) + if (!releaseMemory) { + fail(new Error(options.overflowMessage)) + return Promise.resolve() + } + return new Promise((settle) => { + queue.push({ raw, releaseMemory, retainedBytes: frameBytes, settle }) + retainedBytes += frameBytes + retainedFrames += 1 + drain() + }) + }, + dispose(): void { + if (stopped) { + return + } + stopped = true + dropQueued() + }, + evidence(): MobileInboundFrameQueueEvidence { + return { retainedBytes, retainedFrames, storageSlots: queue.length } + } + } +} + +export function assertMobileInboundFrameSize( + raw: unknown, + message: string, + maxFrameBytes = MOBILE_INBOUND_MAX_FRAME_BYTES +): void { + if (mobileInboundFrameRetainedBytes(raw, maxFrameBytes) > maxFrameBytes) { + throw new Error(message) + } +} + +function mobileInboundFrameRetainedBytes(raw: unknown, unknownFrameBytes: number): number { + if (typeof raw === 'string') { + return raw.length * 2 + } + if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) { + return raw.byteLength + } + if (raw && typeof raw === 'object') { + const size = numericProperty(raw, 'size') ?? numericProperty(raw, 'byteLength') + if (size !== null) { + return size + } + } + // Why: unknown RN payload wrappers may materialize their bytes only during async conversion. + return unknownFrameBytes +} + +function numericProperty(value: object, key: 'size' | 'byteLength'): number | null { + if (!(key in value)) { + return null + } + const candidate = (value as Record)[key] + return typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0 + ? candidate + : null +} diff --git a/mobile/src/transport/mobile-inbound-memory-ledger.test.ts b/mobile/src/transport/mobile-inbound-memory-ledger.test.ts new file mode 100644 index 00000000000..6ebb9aa535f --- /dev/null +++ b/mobile/src/transport/mobile-inbound-memory-ledger.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + createMobileInboundMemoryLedger, + MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES, + MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES +} from './mobile-inbound-memory-ledger' + +describe('mobile inbound memory ledger', () => { + it('uses a process ceiling that preserves two full ordinary queue budgets', () => { + expect(MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES).toBe(192 * 1024 * 1024) + expect(MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES).toBe(256) + }) + + it('makes claims idempotently releasable', () => { + const ledger = createMobileInboundMemoryLedger(5) + const release = ledger.claim(5) + expect(release).not.toBeNull() + expect(ledger.claim(1)).toBeNull() + + release!() + release!() + + expect(ledger.evidence()).toEqual({ + claimCount: 0, + maxRetainedBytes: 5, + maxRetainedFrames: MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES, + retainedBytes: 0 + }) + }) + + it('bounds aggregate zero-byte claims independently of the byte ceiling', () => { + const ledger = createMobileInboundMemoryLedger(5, 2) + const releaseFirst = ledger.claim(0) + const releaseSecond = ledger.claim(0) + + expect(releaseFirst).not.toBeNull() + expect(releaseSecond).not.toBeNull() + expect(ledger.claim(0)).toBeNull() + + releaseFirst!() + releaseSecond!() + }) +}) diff --git a/mobile/src/transport/mobile-inbound-memory-ledger.ts b/mobile/src/transport/mobile-inbound-memory-ledger.ts new file mode 100644 index 00000000000..eb92fca6fe5 --- /dev/null +++ b/mobile/src/transport/mobile-inbound-memory-ledger.ts @@ -0,0 +1,58 @@ +export const MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES = 192 * 1024 * 1024 +export const MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES = 256 + +export type MobileInboundMemoryLedgerEvidence = { + claimCount: number + maxRetainedFrames: number + maxRetainedBytes: number + retainedBytes: number +} + +export type MobileInboundMemoryLedger = { + claim(bytes: number): (() => void) | null + evidence(): MobileInboundMemoryLedgerEvidence +} + +export function createMobileInboundMemoryLedger( + maxRetainedBytes: number, + maxRetainedFrames = MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES +): MobileInboundMemoryLedger { + if ( + !Number.isFinite(maxRetainedBytes) || + maxRetainedBytes < 1 || + !Number.isInteger(maxRetainedFrames) || + maxRetainedFrames < 1 + ) { + throw new Error('Mobile inbound memory limit must be positive') + } + let retainedBytes = 0 + let claimCount = 0 + return { + claim(bytes): (() => void) | null { + if (!Number.isFinite(bytes) || bytes < 0) { + return null + } + if (claimCount >= maxRetainedFrames || retainedBytes + bytes > maxRetainedBytes) { + return null + } + retainedBytes += bytes + claimCount += 1 + let released = false + return () => { + if (released) { + return + } + released = true + retainedBytes -= bytes + claimCount -= 1 + } + }, + evidence(): MobileInboundMemoryLedgerEvidence { + return { claimCount, maxRetainedBytes, maxRetainedFrames, retainedBytes } + } + } +} + +export const processMobileInboundMemoryLedger = createMobileInboundMemoryLedger( + MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES +) diff --git a/mobile/src/transport/mobile-json-text-admission.test.ts b/mobile/src/transport/mobile-json-text-admission.test.ts new file mode 100644 index 00000000000..2e8c12be208 --- /dev/null +++ b/mobile/src/transport/mobile-json-text-admission.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + isMobileJsonStructureCapacityError, + parseMobileJsonTextWithinLimits +} from './mobile-json-text-admission' + +describe('mobile JSON text admission', () => { + it('accepts the exact structural-token limit and rejects one more', () => { + const limits = { structuralTokens: 4, nestingDepth: 2 } + + expect(parseMobileJsonTextWithinLimits('[0,0,0]', limits)).toEqual([0, 0, 0]) + expect(() => parseMobileJsonTextWithinLimits('[0,0,0,0]', limits)).toThrow( + 'JSON structure exceeds 4 tokens' + ) + }) + + it('accepts the exact nesting limit and rejects one more', () => { + const limits = { structuralTokens: 20, nestingDepth: 3 } + + expect(parseMobileJsonTextWithinLimits('[[[]]]', limits)).toEqual([[[]]]) + expect(() => parseMobileJsonTextWithinLimits('[[[[]]]]', limits)).toThrow( + 'JSON nesting exceeds 3 levels' + ) + }) + + it('does not count structural characters inside strings', () => { + expect( + parseMobileJsonTextWithinLimits('{"value":"[{\\\":,}]"}', { + structuralTokens: 3, + nestingDepth: 1 + }) + ).toEqual({ value: '[{":,}]' }) + }) + + it('identifies capacity failures separately from malformed JSON', () => { + const capacityError = captureError(() => + parseMobileJsonTextWithinLimits('[0,0]', { + structuralTokens: 2, + nestingDepth: 2 + }) + ) + const syntaxError = captureError(() => + parseMobileJsonTextWithinLimits('[', { + structuralTokens: 2, + nestingDepth: 2 + }) + ) + + expect(isMobileJsonStructureCapacityError(capacityError)).toBe(true) + expect(isMobileJsonStructureCapacityError(syntaxError)).toBe(false) + }) +}) + +function captureError(run: () => void): unknown { + try { + run() + return null + } catch (error) { + return error + } +} diff --git a/mobile/src/transport/mobile-json-text-admission.ts b/mobile/src/transport/mobile-json-text-admission.ts new file mode 100644 index 00000000000..e12074f57c1 --- /dev/null +++ b/mobile/src/transport/mobile-json-text-admission.ts @@ -0,0 +1,38 @@ +import { + assertJsonTextStructureWithinLimits, + JsonTextStructureCapacityError, + type JsonTextStructureLimits +} from '../../../src/shared/memory-safety/json-text-structure-limit' + +export const MOBILE_JSON_TEXT_STRUCTURE_LIMITS: JsonTextStructureLimits = { + structuralTokens: 1_000_000, + nestingDepth: 128 +} + +export function parseMobileJsonTextWithinLimits( + content: string, + limits: JsonTextStructureLimits = MOBILE_JSON_TEXT_STRUCTURE_LIMITS +): T { + assertJsonTextStructureWithinLimits(content, limits) + return JSON.parse(content) as T +} + +export function isMobileJsonStructureCapacityError( + error: unknown +): error is JsonTextStructureCapacityError { + return error instanceof JsonTextStructureCapacityError +} + +export function tryParseMobileJsonTextWithinLimits( + content: string, + limits: JsonTextStructureLimits = MOBILE_JSON_TEXT_STRUCTURE_LIMITS +): T | null { + try { + return parseMobileJsonTextWithinLimits(content, limits) + } catch (error) { + if (isMobileJsonStructureCapacityError(error)) { + throw error + } + return null + } +} diff --git a/mobile/src/transport/mobile-outbound-json.test.ts b/mobile/src/transport/mobile-outbound-json.test.ts new file mode 100644 index 00000000000..04d459e4780 --- /dev/null +++ b/mobile/src/transport/mobile-outbound-json.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { stringifyMobileOutboundJson } from './mobile-outbound-json' + +describe('bounded mobile outbound JSON serialization', () => { + it('preserves ordinary JSON values', () => { + const value = { text: 'hello', escaped: '\u0000\n', items: [1, true, null] } + + expect(stringifyMobileOutboundJson(value, 1_000)).toBe(JSON.stringify(value)) + }) + + it('stops before traversing fields after an oversized string', () => { + const readAfter = vi.fn(() => 'late') + const value = { + payload: 'x'.repeat(100), + get after() { + return readAfter() + } + } + + expect(() => stringifyMobileOutboundJson(value, 32)).toThrow( + 'Mobile outbound JSON exceeds 32 bytes' + ) + expect(readAfter).not.toHaveBeenCalled() + }) + + it('accounts for JSON escaping before the native serializer allocates output', () => { + expect(stringifyMobileOutboundJson('\u0000', 8)).toBe('"\\u0000"') + expect(() => stringifyMobileOutboundJson('\u0000', 7)).toThrow( + 'Mobile outbound JSON exceeds 7 bytes' + ) + }) + + it('accepts arrays and objects whose serialized form is exactly at the limit', () => { + expect(stringifyMobileOutboundJson([1, 2], 5)).toBe('[1,2]') + expect(stringifyMobileOutboundJson({ a: 1 }, 7)).toBe('{"a":1}') + expect(() => stringifyMobileOutboundJson([1, 2], 4)).toThrow( + 'Mobile outbound JSON exceeds 4 bytes' + ) + expect(() => stringifyMobileOutboundJson({ a: 1 }, 6)).toThrow( + 'Mobile outbound JSON exceeds 6 bytes' + ) + }) + + it('does not charge object properties omitted by JSON.stringify', () => { + const value = { + omittedUndefined: undefined, + omittedFunction: () => 'ignored', + omittedSymbol: Symbol('ignored'), + kept: 1 + } + + expect(stringifyMobileOutboundJson(value, 10)).toBe('{"kept":1}') + }) + + it('serializes unsupported array elements as null within the exact limit', () => { + const value = [undefined, () => 'ignored', Symbol('ignored')] + + expect(stringifyMobileOutboundJson(value, 16)).toBe('[null,null,null]') + expect(() => stringifyMobileOutboundJson(value, 15)).toThrow( + 'Mobile outbound JSON exceeds 15 bytes' + ) + }) + + it('accounts for boxed JSON primitives without underestimating them', () => { + expect(stringifyMobileOutboundJson(new String('\u0000'), 8)).toBe('"\\u0000"') + expect(stringifyMobileOutboundJson(new Number(12345), 5)).toBe('12345') + expect(stringifyMobileOutboundJson(new Boolean(false), 5)).toBe('false') + expect(() => stringifyMobileOutboundJson(new String('\u0000'), 7)).toThrow( + 'Mobile outbound JSON exceeds 7 bytes' + ) + }) +}) diff --git a/mobile/src/transport/mobile-outbound-json.ts b/mobile/src/transport/mobile-outbound-json.ts new file mode 100644 index 00000000000..fc367f985eb --- /dev/null +++ b/mobile/src/transport/mobile-outbound-json.ts @@ -0,0 +1,123 @@ +export const MOBILE_OUTBOUND_MAX_JSON_BYTES = 4 * 1024 * 1024 + +export function stringifyMobileOutboundJson( + value: unknown, + maxBytes = MOBILE_OUTBOUND_MAX_JSON_BYTES +): string { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new Error('Mobile outbound JSON limit must be a positive safe integer') + } + let estimatedBytes = 0 + let root = true + const emittedMembers = new WeakMap() + const serialized = JSON.stringify(value, function (key, item: unknown) { + const isRoot = root + root = false + const parent = this as object + const inArray = Array.isArray(parent) + if (!isRoot && !inArray && isOmittedObjectValue(item)) { + return item + } + if (!isRoot) { + const emitted = emittedMembers.get(parent) ?? 0 + estimatedBytes += emitted > 0 ? 1 : 0 + if (!inArray) { + estimatedBytes += escapedJsonStringBytes(key) + 1 + } + emittedMembers.set(parent, emitted + 1) + } + estimatedBytes += inArray && isOmittedObjectValue(item) ? 4 : jsonValueBytes(item) + if (estimatedBytes > maxBytes) { + throw new Error(`Mobile outbound JSON exceeds ${maxBytes} bytes`) + } + return item + }) + if (serialized === undefined) { + throw new Error('Mobile outbound value is not JSON serializable') + } + return serialized +} + +function isOmittedObjectValue(value: unknown): boolean { + return value === undefined || typeof value === 'function' || typeof value === 'symbol' +} + +function jsonValueBytes(value: unknown): number { + if (value === null) { + return 4 + } + if (typeof value === 'string') { + return escapedJsonStringBytes(value) + } + if (typeof value === 'boolean') { + return value ? 4 : 5 + } + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value).length : 4 + } + if (typeof value === 'object' && value !== null) { + return boxedPrimitiveJsonBytes(value) ?? 2 + } + return 4 +} + +function boxedPrimitiveJsonBytes(value: object): number | null { + try { + return escapedJsonStringBytes(String.prototype.valueOf.call(value)) + } catch {} + try { + const number = Number.prototype.valueOf.call(value) + return Number.isFinite(number) ? String(number).length : 4 + } catch {} + try { + return Boolean.prototype.valueOf.call(value) ? 4 : 5 + } catch { + return null + } +} + +function escapedJsonStringBytes(value: string): number { + let bytes = 2 + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if ( + code === 0x22 || + code === 0x5c || + code === 0x08 || + code === 0x09 || + code === 0x0a || + code === 0x0c || + code === 0x0d + ) { + bytes += 2 + } else if ( + code <= 0x1f || + (code >= 0xd800 && code <= 0xdfff && !isSurrogatePair(value, index)) + ) { + bytes += 6 + } else if (code <= 0x7f) { + bytes += 1 + } else if (code <= 0x7ff) { + bytes += 2 + } else if (code >= 0xd800 && code <= 0xdbff) { + bytes += 4 + index += 1 + } else { + bytes += 3 + } + } + return bytes +} + +function isSurrogatePair(value: string, index: number): boolean { + const code = value.charCodeAt(index) + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + return next >= 0xdc00 && next <= 0xdfff + } + if (code >= 0xdc00 && code <= 0xdfff) { + const previous = value.charCodeAt(index - 1) + return previous >= 0xd800 && previous <= 0xdbff + } + return false +} diff --git a/mobile/src/transport/mobile-outbound-memory-budget.test.ts b/mobile/src/transport/mobile-outbound-memory-budget.test.ts new file mode 100644 index 00000000000..832e4e9ecbe --- /dev/null +++ b/mobile/src/transport/mobile-outbound-memory-budget.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { + createMobileOutboundMemoryBudget, + MOBILE_OUTBOUND_MAX_FRAME_BYTES, + MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES, + MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES, + MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES, + MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES, + MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES +} from './mobile-outbound-memory-budget' + +describe('mobile outbound memory budget', () => { + it('keeps ordinary two-socket bursts within explicit process ceilings', () => { + expect(MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES).toBe(16 * 1024 * 1024) + expect(MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES).toBe(16_384) + expect(MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES).toBe(128 * 1024 * 1024) + expect(MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES).toBe(16_384) + expect(MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES).toBe(64) + expect(MOBILE_OUTBOUND_MAX_FRAME_BYTES).toBe(8 * 1024 * 1024) + }) + + it('sums native bufferedAmount across registered physical sockets', () => { + const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) + let first = 4 + const firstSource = budget.registerBufferedAmount(() => first) + const secondSource = budget.registerBufferedAmount(() => 6) + expect(firstSource.canSend(0)).toBe(true) + + first = 5 + expect(firstSource.canSend(0)).toBe(false) + secondSource.release() + firstSource.release() + expect(budget.evidence()).toMatchObject({ bufferedBytes: 0, bufferedSourceCount: 0 }) + }) + + it('reserves room for the prospective native frame before allowing a send', () => { + const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) + const source = budget.registerBufferedAmount(() => 6) + + expect(source.canSend(4)).toBe(true) + expect(source.canSend(5)).toBe(false) + }) + + it('counts unacknowledged frames when React Native does not expose bufferedAmount', () => { + const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) + const source = budget.registerBufferedAmount(() => Number.NaN) + const release = source.claimInFlightBytes(7) + + expect(release).not.toBeNull() + expect(source.canSend(4)).toBe(false) + expect(budget.evidence()).toMatchObject({ + bufferedBytes: 7, + inFlightBytes: 7, + inFlightClaimCount: 1 + }) + + release!() + expect(source.canSend(10)).toBe(true) + }) + + it('caps sent-frame claims and retired socket readers independently', () => { + const budget = createMobileOutboundMemoryBudget({ + maxBufferedBytes: 10, + maxBufferedSources: 1, + maxInFlightFrames: 1 + }) + const source = budget.registerBufferedAmount(() => 0) + const release = source.claimInFlightBytes(0) + + expect(release).not.toBeNull() + expect(source.claimInFlightBytes(0)).toBeNull() + expect(budget.canRegisterBufferedAmount()).toBe(false) + expect(() => budget.registerBufferedAmount(() => 0)).toThrow( + 'Mobile outbound socket tracking limit exceeded' + ) + + release!() + source.release() + expect(budget.canRegisterBufferedAmount()).toBe(true) + }) + + it('bounds aggregate JavaScript backlog and releases claims idempotently', () => { + const budget = createMobileOutboundMemoryBudget({ maxQueuedBytes: 5 }) + const release = budget.claimQueuedBytes(5) + expect(release).not.toBeNull() + expect(budget.claimQueuedBytes(1)).toBeNull() + + release!() + release!() + + expect(budget.evidence()).toMatchObject({ queuedBytes: 0, queuedClaimCount: 0 }) + }) + + it('bounds aggregate zero-byte claims independently of the byte ceiling', () => { + const budget = createMobileOutboundMemoryBudget({ maxQueuedBytes: 5, maxQueuedFrames: 2 }) + const releaseFirst = budget.claimQueuedBytes(0) + const releaseSecond = budget.claimQueuedBytes(0) + + expect(releaseFirst).not.toBeNull() + expect(releaseSecond).not.toBeNull() + expect(budget.claimQueuedBytes(0)).toBeNull() + + releaseFirst!() + releaseSecond!() + }) +}) diff --git a/mobile/src/transport/mobile-outbound-memory-budget.ts b/mobile/src/transport/mobile-outbound-memory-budget.ts new file mode 100644 index 00000000000..2b94690a0c6 --- /dev/null +++ b/mobile/src/transport/mobile-outbound-memory-budget.ts @@ -0,0 +1,188 @@ +export const MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES = 16 * 1024 * 1024 +export const MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES = 128 * 1024 * 1024 +export const MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES = 16_384 +export const MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES = 16_384 +export const MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES = 64 +export const MOBILE_OUTBOUND_MAX_FRAME_BYTES = 8 * 1024 * 1024 + +export type MobileOutboundMemoryBudgetEvidence = { + bufferedBytes: number + bufferedSourceCount: number + inFlightBytes: number + inFlightClaimCount: number + queuedBytes: number + queuedClaimCount: number +} + +export type MobileOutboundSocketMemory = { + canSend(bytes: number): boolean + claimInFlightBytes(bytes: number): (() => void) | null + release(): void +} + +export type MobileOutboundMemoryBudget = { + canRegisterBufferedAmount(): boolean + claimQueuedBytes(bytes: number): (() => void) | null + evidence(): MobileOutboundMemoryBudgetEvidence + registerBufferedAmount(readBufferedAmount: () => number): MobileOutboundSocketMemory +} + +export function createMobileOutboundMemoryBudget(options?: { + maxBufferedBytes?: number + maxBufferedSources?: number + maxInFlightFrames?: number + maxQueuedBytes?: number + maxQueuedFrames?: number +}): MobileOutboundMemoryBudget { + const maxBufferedBytes = options?.maxBufferedBytes ?? MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES + const maxBufferedSources = + options?.maxBufferedSources ?? MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES + const maxInFlightFrames = + options?.maxInFlightFrames ?? MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES + const maxQueuedBytes = options?.maxQueuedBytes ?? MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES + const maxQueuedFrames = options?.maxQueuedFrames ?? MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES + if ( + !Number.isFinite(maxBufferedBytes) || + maxBufferedBytes < 1 || + !Number.isInteger(maxBufferedSources) || + maxBufferedSources < 1 || + !Number.isInteger(maxInFlightFrames) || + maxInFlightFrames < 1 || + !Number.isFinite(maxQueuedBytes) || + maxQueuedBytes < 1 || + !Number.isInteger(maxQueuedFrames) || + maxQueuedFrames < 1 + ) { + throw new Error('Mobile outbound memory limits must be positive') + } + type BufferedSource = { + inFlightBytes: number + inFlightClaimCount: number + readBufferedAmount: () => number + } + const bufferedSources = new Set() + let queuedBytes = 0 + let queuedClaimCount = 0 + + const bufferedBytes = (): number => { + let total = 0 + for (const source of bufferedSources) { + let observed = 0 + try { + const value = source.readBufferedAmount() + if (Number.isFinite(value) && value > 0) { + observed = value + } + } catch { + // Closed native sockets may reject late reads; their close path unregisters them. + } + // Some runtimes update bufferedAmount asynchronously, so unacknowledged + // claims remain additive instead of assuming the native value includes them. + total += observed + source.inFlightBytes + } + return total + } + + const inFlightClaimCount = (): number => { + let count = 0 + for (const source of bufferedSources) { + count += source.inFlightClaimCount + } + return count + } + + const inFlightBytes = (): number => { + let total = 0 + for (const source of bufferedSources) { + total += source.inFlightBytes + } + return total + } + + return { + canRegisterBufferedAmount: () => bufferedSources.size < maxBufferedSources, + claimQueuedBytes(bytes): (() => void) | null { + if ( + !Number.isFinite(bytes) || + bytes < 0 || + queuedClaimCount >= maxQueuedFrames || + queuedBytes + bytes > maxQueuedBytes + ) { + return null + } + queuedBytes += bytes + queuedClaimCount += 1 + let released = false + return () => { + if (released) { + return + } + released = true + queuedBytes -= bytes + queuedClaimCount -= 1 + } + }, + evidence(): MobileOutboundMemoryBudgetEvidence { + return { + bufferedBytes: bufferedBytes(), + bufferedSourceCount: bufferedSources.size, + inFlightBytes: inFlightBytes(), + inFlightClaimCount: inFlightClaimCount(), + queuedBytes, + queuedClaimCount + } + }, + registerBufferedAmount(readBufferedAmount): MobileOutboundSocketMemory { + if (bufferedSources.size >= maxBufferedSources) { + throw new Error('Mobile outbound socket tracking limit exceeded') + } + const source: BufferedSource = { + inFlightBytes: 0, + inFlightClaimCount: 0, + readBufferedAmount + } + bufferedSources.add(source) + let registered = true + return { + canSend(bytes): boolean { + return ( + registered && + Number.isFinite(bytes) && + bytes >= 0 && + inFlightClaimCount() < maxInFlightFrames && + bytes <= maxBufferedBytes - bufferedBytes() + ) + }, + claimInFlightBytes(bytes): (() => void) | null { + if (!this.canSend(bytes)) { + return null + } + source.inFlightBytes += bytes + source.inFlightClaimCount += 1 + let released = false + return () => { + if (released) { + return + } + released = true + if (registered) { + source.inFlightBytes -= bytes + source.inFlightClaimCount -= 1 + } + } + }, + release(): void { + if (!registered) { + return + } + registered = false + bufferedSources.delete(source) + source.inFlightBytes = 0 + source.inFlightClaimCount = 0 + } + } + } + } +} + +export const processMobileOutboundMemoryBudget = createMobileOutboundMemoryBudget() diff --git a/mobile/src/transport/mobile-outbound-socket-ledger.test.ts b/mobile/src/transport/mobile-outbound-socket-ledger.test.ts new file mode 100644 index 00000000000..5dd43ede6dd --- /dev/null +++ b/mobile/src/transport/mobile-outbound-socket-ledger.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget' +import { + createMobileOutboundSocketLedger, + MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS +} from './mobile-outbound-socket-ledger' + +describe('mobile outbound socket ledger', () => { + afterEach(() => vi.useRealTimers()) + + it('releases only the matching delivered frame while the socket stays open', () => { + const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) + const ledger = createMobileOutboundSocketLedger({ + memoryBudget: budget, + readBufferedAmount: () => Number.NaN + }) + + expect(ledger.claimSentBytes(4, 'rpc-1')).not.toBeNull() + expect(ledger.claimSentBytes(5, 'rpc-2')).not.toBeNull() + expect(ledger.canSend(2)).toBe(false) + + ledger.acknowledge('rpc-1') + + expect(budget.evidence()).toMatchObject({ inFlightBytes: 5, inFlightClaimCount: 1 }) + expect(ledger.canSend(5)).toBe(true) + }) + + it('keeps unacknowledged bytes counted across logical disposal until native close', () => { + const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) + const ledger = createMobileOutboundSocketLedger({ + memoryBudget: budget, + readBufferedAmount: () => Number.NaN + }) + expect(ledger.claimSentBytes(6, 'rpc-1')).not.toBeNull() + + expect(budget.evidence()).toMatchObject({ bufferedBytes: 6, bufferedSourceCount: 1 }) + + ledger.socketClosed() + ledger.socketClosed() + expect(budget.evidence()).toMatchObject({ + bufferedBytes: 0, + bufferedSourceCount: 0, + inFlightClaimCount: 0 + }) + }) + + it('releases a retired socket after a missing native close callback', () => { + vi.useFakeTimers() + const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) + const ledger = createMobileOutboundSocketLedger({ + memoryBudget: budget, + readBufferedAmount: () => Number.NaN + }) + expect(ledger.claimSentBytes(6, 'rpc-1')).not.toBeNull() + + ledger.retire() + vi.advanceTimersByTime(MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS - 1) + expect(budget.evidence().bufferedSourceCount).toBe(1) + + vi.advanceTimersByTime(1) + expect(budget.evidence()).toMatchObject({ + bufferedBytes: 0, + bufferedSourceCount: 0, + inFlightClaimCount: 0 + }) + }) + + it('settles unkeyed frames after a runtime reports two drained polls', () => { + vi.useFakeTimers() + const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) + const ledger = createMobileOutboundSocketLedger({ + memoryBudget: budget, + readBufferedAmount: () => 0 + }) + expect(ledger.claimSentBytes(6)).not.toBeNull() + + vi.advanceTimersByTime(50) + + expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 }) + ledger.socketClosed() + }) + + it('requires two fresh drained polls after another unkeyed frame is sent', () => { + vi.useFakeTimers() + const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) + const ledger = createMobileOutboundSocketLedger({ + memoryBudget: budget, + readBufferedAmount: () => 0 + }) + expect(ledger.claimSentBytes(4)).not.toBeNull() + + vi.advanceTimersByTime(25) + expect(ledger.claimSentBytes(2)).not.toBeNull() + vi.advanceTimersByTime(25) + + expect(budget.evidence()).toMatchObject({ inFlightBytes: 6, inFlightClaimCount: 2 }) + vi.advanceTimersByTime(25) + expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 }) + ledger.socketClosed() + }) + + it('keeps unkeyed React Native claims until close when bufferedAmount is unavailable', () => { + vi.useFakeTimers() + const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) + const ledger = createMobileOutboundSocketLedger({ + memoryBudget: budget, + readBufferedAmount: () => Number.NaN + }) + expect(ledger.claimSentBytes(6)).not.toBeNull() + + vi.advanceTimersByTime(1_000) + expect(budget.evidence()).toMatchObject({ inFlightBytes: 6, inFlightClaimCount: 1 }) + + ledger.socketClosed() + expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 }) + }) +}) diff --git a/mobile/src/transport/mobile-outbound-socket-ledger.ts b/mobile/src/transport/mobile-outbound-socket-ledger.ts new file mode 100644 index 00000000000..7ad1e41d1f0 --- /dev/null +++ b/mobile/src/transport/mobile-outbound-socket-ledger.ts @@ -0,0 +1,151 @@ +import type { MobileOutboundMemoryBudget } from './mobile-outbound-memory-budget' + +type InFlightClaim = { + acknowledgementKey?: string + releaseBytes: () => void +} + +export const MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS = 30_000 +const NATIVE_DRAIN_POLL_MS = 25 + +export type MobileOutboundSocketLedger = { + acknowledge(key: string): void + canSend(bytes: number): boolean + claimSentBytes(bytes: number, acknowledgementKey?: string): (() => void) | null + retire(): void + socketClosed(): void +} + +export function createMobileOutboundSocketLedger(args: { + memoryBudget: MobileOutboundMemoryBudget + readBufferedAmount: () => number +}): MobileOutboundSocketLedger { + const socketMemory = args.memoryBudget.registerBufferedAmount(args.readBufferedAmount) + const activeClaims = new Set() + const anonymousClaims = new Set() + const claimsByAcknowledgement = new Map>() + let drainTimer: ReturnType | null = null + let retireTimer: ReturnType | null = null + let consecutiveZeroDrainPolls = 0 + let closed = false + + const settleClaim = (claim: InFlightClaim): void => { + if (!activeClaims.delete(claim)) { + return + } + claim.releaseBytes() + if (claim.acknowledgementKey === undefined) { + anonymousClaims.delete(claim) + return + } + const keyed = claimsByAcknowledgement.get(claim.acknowledgementKey) + keyed?.delete(claim) + if (keyed?.size === 0) { + claimsByAcknowledgement.delete(claim.acknowledgementKey) + } + } + + const clearTimer = (timer: ReturnType | null): void => { + if (timer) { + clearTimeout(timer) + } + } + + const pollNativeDrain = (): void => { + drainTimer = null + if (closed || anonymousClaims.size === 0) { + return + } + let bufferedAmount: number + try { + bufferedAmount = args.readBufferedAmount() + } catch { + return + } + // React Native leaves this undefined; keyed application ACKs remain the authoritative path there. + if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) { + return + } + consecutiveZeroDrainPolls = bufferedAmount === 0 ? consecutiveZeroDrainPolls + 1 : 0 + if (consecutiveZeroDrainPolls >= 2) { + for (const claim of anonymousClaims) { + settleClaim(claim) + } + return + } + drainTimer = setTimeout(pollNativeDrain, NATIVE_DRAIN_POLL_MS) + unrefTimer(drainTimer) + } + + const scheduleNativeDrainPoll = (): void => { + if (drainTimer || closed) { + return + } + drainTimer = setTimeout(pollNativeDrain, NATIVE_DRAIN_POLL_MS) + unrefTimer(drainTimer) + } + + const closeLedger = (): void => { + if (closed) { + return + } + closed = true + clearTimer(drainTimer) + clearTimer(retireTimer) + drainTimer = null + retireTimer = null + for (const claim of activeClaims) { + settleClaim(claim) + } + anonymousClaims.clear() + claimsByAcknowledgement.clear() + socketMemory.release() + } + + return { + acknowledge(key): void { + const claims = claimsByAcknowledgement.get(key) + if (!claims) { + return + } + for (const claim of claims) { + settleClaim(claim) + } + }, + canSend: (bytes) => socketMemory.canSend(bytes), + claimSentBytes(bytes, acknowledgementKey): (() => void) | null { + const releaseBytes = socketMemory.claimInFlightBytes(bytes) + if (!releaseBytes) { + return null + } + const claim: InFlightClaim = { acknowledgementKey, releaseBytes } + activeClaims.add(claim) + if (acknowledgementKey === undefined) { + anonymousClaims.add(claim) + consecutiveZeroDrainPolls = 0 + scheduleNativeDrainPoll() + } else { + let claims = claimsByAcknowledgement.get(acknowledgementKey) + if (!claims) { + claims = new Set() + claimsByAcknowledgement.set(acknowledgementKey, claims) + } + claims.add(claim) + } + return () => settleClaim(claim) + }, + retire(): void { + if (closed || retireTimer) { + return + } + retireTimer = setTimeout(closeLedger, MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS) + unrefTimer(retireTimer) + }, + socketClosed: closeLedger + } +} + +function unrefTimer(timer: ReturnType): void { + const nodeTimer = timer as unknown as { unref?: () => void } + nodeTimer.unref?.() +} diff --git a/mobile/src/transport/mobile-relay-credential-bundle.test.ts b/mobile/src/transport/mobile-relay-credential-bundle.test.ts index 4df9d44430b..47203867a7e 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.test.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.test.ts @@ -14,12 +14,14 @@ vi.mock('expo-secure-store', () => ({ vi.mock('react-native', () => ({ Platform: platform })) import { + MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS, deleteMobileRelayCredentialBundle, promotePairingJournalCredential, readMobileRelayCredentialBundle, writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { MOBILE_HOST_ID_MAX_CHARACTERS, PAIRING_DEVICE_TOKEN_MAX_CHARACTERS } from './types' const journal = { metadata: { @@ -106,6 +108,51 @@ describe('mobile relay credential bundle', () => { ).toThrow(/does not match/) }) + it('round-trips exact field and raw limits and rejects one character more', async () => { + const exact = { + v: 1 as const, + hostId: 'h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS), + deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 1, + expiresAt: 10_000 + } + } + + await expect(writeMobileRelayCredentialBundle(exact)).resolves.toBeUndefined() + const serialized = stored! + stored = + serialized + + ' '.repeat(MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS - serialized.length) + await expect(readMobileRelayCredentialBundle(exact.hostId)).resolves.toEqual(exact) + await expect( + writeMobileRelayCredentialBundle({ + ...exact, + deviceToken: `${exact.deviceToken}t` + }) + ).rejects.toThrow() + }) + + it('does not parse an oversized secure-store record', async () => { + stored = { + length: MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS + 1 + } as unknown as string + const parse = vi.spyOn(JSON, 'parse') + + await expect(readMobileRelayCredentialBundle('host-1')).resolves.toBeNull() + expect(parse).not.toHaveBeenCalled() + parse.mockRestore() + }) + + it('does not construct a secure-store key for an oversized host id', async () => { + await expect( + readMobileRelayCredentialBundle('h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS + 1)) + ).resolves.toBeNull() + expect(secureStore.getItemAsync).not.toHaveBeenCalled() + }) + it('deletes the namespaced bundle and never enables it on web', async () => { await deleteMobileRelayCredentialBundle('host-1') expect(secureStore.deleteItemAsync).toHaveBeenCalledWith( diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts index 87f6f3826f4..cb3e178dc83 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -3,8 +3,15 @@ import { Platform } from 'react-native' import { z } from 'zod' import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { + MOBILE_HOST_ID_MAX_CHARACTERS, + MobileHostIdSchema, + PAIRING_DEVICE_TOKEN_MAX_CHARACTERS +} from './types' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +export const MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS = 128 * 1024 const ResumeCredentialSchema = z .object({ token: Base64Url32ByteSchema, @@ -17,15 +24,15 @@ const ResumeCredentialSchema = z export const MobileRelayCredentialBundleSchema = z .object({ v: z.literal(1), - hostId: z.string().min(1), - deviceToken: z.string().min(1), + hostId: MobileHostIdSchema, + deviceToken: z.string().min(1).max(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), current: ResumeCredentialSchema, grace: ResumeCredentialSchema.optional(), pending: z .object({ token: Base64Url32ByteSchema, hash: Base64Url32ByteSchema, - reqId: z.string().min(1) + reqId: z.string().min(1).max(128) }) .strict() .optional(), @@ -74,12 +81,15 @@ export async function readMobileRelayCredentialBundle( hostId: string ): Promise { requireNativeSecretStore() + if (!isValidHostId(hostId)) { + return null + } const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) - if (raw === null) { + if (raw === null || raw.length > MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS) { return null } try { - const result = MobileRelayCredentialBundleSchema.safeParse(JSON.parse(raw)) + const result = MobileRelayCredentialBundleSchema.safeParse(parseMobileJsonTextWithinLimits(raw)) return result.success && result.data.hostId === hostId ? result.data : null } catch { return null @@ -91,20 +101,24 @@ export async function writeMobileRelayCredentialBundle( ): Promise { requireNativeSecretStore() const validated = MobileRelayCredentialBundleSchema.parse(bundle) - await SecureStore.setItemAsync( - credentialKey(validated.hostId), - JSON.stringify(validated), - KEYCHAIN_OPTIONS - ) + const serialized = JSON.stringify(validated) + if (serialized.length > MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS) { + throw new Error('mobile relay credential bundle exceeds storage limit') + } + await SecureStore.setItemAsync(credentialKey(validated.hostId), serialized, KEYCHAIN_OPTIONS) } export async function deleteMobileRelayCredentialBundle(hostId: string): Promise { - if (Platform.OS === 'web') { + if (Platform.OS === 'web' || !isValidHostId(hostId)) { return } await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) } +function isValidHostId(hostId: string): boolean { + return hostId.length > 0 && hostId.length <= MOBILE_HOST_ID_MAX_CHARACTERS +} + function requireNativeSecretStore(): void { if (Platform.OS === 'web') { throw new Error('Orca Relay credentials require a native secret store') diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.test.ts new file mode 100644 index 00000000000..6a2ce32ea57 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const secureStore = vi.hoisted(() => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + deleteItemAsync: vi.fn() +})) +const platform = vi.hoisted(() => ({ OS: 'ios' })) + +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStore +})) +vi.mock('react-native', () => ({ Platform: platform })) + +import { + MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS, + createMobileRelayDirectUpgradeJournal, + readMobileRelayDirectUpgradeJournal, + writeMobileRelayDirectUpgradeJournal +} from './mobile-relay-direct-upgrade-journal' +import { MOBILE_HOST_ID_MAX_CHARACTERS } from './types' + +describe('mobile relay direct-upgrade journal storage', () => { + let stored: string | null + + beforeEach(() => { + vi.clearAllMocks() + platform.OS = 'ios' + stored = null + secureStore.getItemAsync.mockImplementation(async () => stored) + secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { + stored = value + }) + }) + + it('round-trips the exact host-id and raw limits', async () => { + const hostId = 'h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS) + const journal = createMobileRelayDirectUpgradeJournal(hostId, (length) => + new Uint8Array(length).fill(7) + ) + + await writeMobileRelayDirectUpgradeJournal(journal) + const serialized = stored! + stored = + serialized + + ' '.repeat(MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS - serialized.length) + + await expect(readMobileRelayDirectUpgradeJournal(hostId)).resolves.toEqual(journal) + }) + + it('rejects a host id one character beyond the limit', () => { + expect(() => + createMobileRelayDirectUpgradeJournal( + 'h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS + 1), + (length) => new Uint8Array(length) + ) + ).toThrow() + }) + + it('does not parse an oversized secure-store record', async () => { + stored = { + length: MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS + 1 + } as unknown as string + const parse = vi.spyOn(JSON, 'parse') + + await expect(readMobileRelayDirectUpgradeJournal('host-1')).resolves.toBeNull() + expect(parse).not.toHaveBeenCalled() + parse.mockRestore() + }) + + it('does not construct a secure-store key for an oversized host id', async () => { + await expect( + readMobileRelayDirectUpgradeJournal('h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS + 1)) + ).resolves.toBeNull() + expect(secureStore.getItemAsync).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 19775270b45..7fd3327e7dc 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -2,13 +2,16 @@ import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { z } from 'zod' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' +import { MOBILE_HOST_ID_MAX_CHARACTERS, MobileHostIdSchema } from './types' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +export const MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS = 32 * 1024 export const MobileRelayDirectUpgradeJournalSchema = z .object({ v: z.literal(1), - hostId: z.string().min(1), + hostId: MobileHostIdSchema, reqId: z.string().min(1).max(128), pendingResumeToken: Base64Url32ByteSchema, pendingResumeTokenHash: Base64Url32ByteSchema @@ -43,12 +46,17 @@ export async function readMobileRelayDirectUpgradeJournal( hostId: string ): Promise { requireNativeSecretStore() + if (!isValidHostId(hostId)) { + return null + } const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) - if (!raw) { + if (!raw || raw.length > MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS) { return null } try { - const parsed = MobileRelayDirectUpgradeJournalSchema.safeParse(JSON.parse(raw)) + const parsed = MobileRelayDirectUpgradeJournalSchema.safeParse( + parseMobileJsonTextWithinLimits(raw) + ) return parsed.success && parsed.data.hostId === hostId ? parsed.data : null } catch { return null @@ -60,20 +68,24 @@ export async function writeMobileRelayDirectUpgradeJournal( ): Promise { requireNativeSecretStore() const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) - await SecureStore.setItemAsync( - journalKey(parsed.hostId), - JSON.stringify(parsed), - KEYCHAIN_OPTIONS - ) + const serialized = JSON.stringify(parsed) + if (serialized.length > MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS) { + throw new Error('mobile relay direct-upgrade journal exceeds storage limit') + } + await SecureStore.setItemAsync(journalKey(parsed.hostId), serialized, KEYCHAIN_OPTIONS) } export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise { - if (Platform.OS === 'web') { + if (Platform.OS === 'web' || !isValidHostId(hostId)) { return } await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) } +function isValidHostId(hostId: string): boolean { + return hostId.length > 0 && hostId.length <= MOBILE_HOST_ID_MAX_CHARACTERS +} + function encodeBase64Url(value: Uint8Array): string { let binary = '' for (const byte of value) { diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index 183a8deea6d..0856732e3cc 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -4,7 +4,9 @@ import { } from '../../../src/shared/mobile-relay-phone-protocol' import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' +import { assertMobileInboundFrameSize } from './mobile-inbound-frame-queue' import { websocketPayloadToUint8 } from './websocket-payload-bytes' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' export class RelayOuterError extends Error { constructor(readonly code: number) { @@ -32,7 +34,6 @@ export class MobileRelayE2eeLink { private readonly channel: MobileE2EEV2PhysicalChannel private outerReady = false private closed = false - private inboundChain: Promise = Promise.resolve() constructor(options: MobileRelayE2eeLinkOptions) { this.options = options @@ -44,16 +45,21 @@ export class MobileRelayE2eeLink { transport: 'relay', relayHostId: options.endpoint.relayHostId }) - this.channel = new MobileE2EEV2PhysicalChannel({ - session, - socket: this.socket, - deviceToken: options.deviceToken, - decodeBinary: websocketPayloadToUint8, - onAuthenticated: options.onAuthenticated, - onText: options.onText, - onBinary: options.onBinary, - onError: (error) => this.fail(error) - }) + try { + this.channel = new MobileE2EEV2PhysicalChannel({ + session, + socket: this.socket, + deviceToken: options.deviceToken, + decodeBinary: websocketPayloadToUint8, + onAuthenticated: options.onAuthenticated, + onText: options.onText, + onBinary: options.onBinary, + onError: (error) => this.fail(error) + }) + } catch (error) { + this.socket.close() + throw error + } this.bindSocket() } @@ -86,21 +92,25 @@ export class MobileRelayE2eeLink { ) } this.socket.onmessage = (event) => { - this.inboundChain = this.inboundChain - .then(async () => { - if (this.closed) { - return - } - if (!this.outerReady) { - this.acceptHello(event.data) - } else { - await this.channel.handleMessage(event.data) - } - }) - .catch((error: unknown) => this.fail(asError(error))) + if (this.closed) { + return + } + try { + if (!this.outerReady) { + assertMobileInboundFrameSize(event.data, 'relay hello frame too large') + this.acceptHello(event.data) + } else { + void this.channel.handleMessage(event.data) + } + } catch (error) { + this.fail(asError(error)) + } } this.socket.onerror = () => this.fail(new Error('relay transport error')) - this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006)) + this.socket.onclose = (event) => { + this.channel.socketClosed() + this.fail(new RelayOuterError(event.code || 1006)) + } } private acceptHello(raw: unknown): void { @@ -109,7 +119,7 @@ export class MobileRelayE2eeLink { } let value: unknown try { - value = JSON.parse(raw) + value = parseMobileJsonTextWithinLimits(raw) } catch { throw new Error('invalid relay hello JSON') } diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts index 812d61ebb1d..0c078b91e1e 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts @@ -9,6 +9,8 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStor import { loadMobileRelayHostOverlays, + MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES, + MOBILE_RELAY_HOST_OVERLAY_MAX_STORAGE_CHARACTERS, removeMobileRelayHostOverlays, resetMobileRelayHostOverlayStoreForTests, saveMobileRelayHostOverlay @@ -98,4 +100,27 @@ describe('mobile relay host overlay store', () => { expect(asyncStorage.getItem).toHaveBeenCalledOnce() expect(asyncStorage.setItem).not.toHaveBeenCalled() }) + + it('accepts the exact overlay count and refuses to overwrite one over', async () => { + const exact = Array.from({ length: MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES }, (_, index) => ({ + ...OVERLAY, + hostId: `host-${index}` + })) + stored = JSON.stringify(exact) + const hostIds = new Set(exact.map(({ hostId }) => hostId)) + expect((await loadMobileRelayHostOverlays(hostIds)).size).toBe( + MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES + ) + + stored = JSON.stringify([...exact, { ...OVERLAY, hostId: 'one-over' }]) + await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/) + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) + + it('rejects an oversized overlay payload before parsing', async () => { + stored = 'x'.repeat(MOBILE_RELAY_HOST_OVERLAY_MAX_STORAGE_CHARACTERS + 1) + + await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/) + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts index 7f1519eb5c9..ad6f399b3d2 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -3,23 +3,36 @@ import { MobileRelayHostOverlaySchema, type MobileRelayHostOverlay } from './mobile-relay-host-overlay' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' +export const MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES = 64 +export const MOBILE_RELAY_HOST_OVERLAY_MAX_STORAGE_CHARACTERS = 512 * 1024 let overlayMutation: Promise = Promise.resolve() function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null { if (raw === null) { return [] } + if (raw.length > MOBILE_RELAY_HOST_OVERLAY_MAX_STORAGE_CHARACTERS) { + return null + } try { - const value = JSON.parse(raw) as unknown + const value = parseMobileJsonTextWithinLimits(raw) if (!Array.isArray(value)) { return null } - return value.flatMap((item) => { + if (value.length > MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES) { + return null + } + const overlays: MobileRelayHostOverlay[] = [] + for (const item of value) { const result = MobileRelayHostOverlaySchema.safeParse(item) - return result.success ? [result.data] : [] - }) + if (result.success) { + overlays.push(result.data) + } + } + return overlays } catch { return null } @@ -44,13 +57,24 @@ async function mutateOverlays( // Why: direct-only saves commonly have no overlay to remove; avoid a full // AsyncStorage write when cleanup leaves the durable list unchanged. if (next !== current) { - await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next)) + await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, serializeOverlays(next)) } }) overlayMutation = mutation.catch(() => {}) return mutation } +function serializeOverlays(overlays: MobileRelayHostOverlay[]): string { + if (overlays.length > MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES) { + throw new Error('mobile relay host overlay count limit exceeded') + } + const serialized = JSON.stringify(overlays) + if (serialized.length > MOBILE_RELAY_HOST_OVERLAY_MAX_STORAGE_CHARACTERS) { + throw new Error('mobile relay host overlay storage limit exceeded') + } + return serialized +} + export async function loadMobileRelayHostOverlays( existingHostIds: ReadonlySet ): Promise> { diff --git a/mobile/src/transport/mobile-relay-host-overlay.ts b/mobile/src/transport/mobile-relay-host-overlay.ts index 59c407c9ee3..ae15eefdf61 100644 --- a/mobile/src/transport/mobile-relay-host-overlay.ts +++ b/mobile/src/transport/mobile-relay-host-overlay.ts @@ -1,6 +1,8 @@ import { z } from 'zod' import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' +export const MOBILE_RELAY_HOST_OVERLAY_MAX_HOST_ID_CHARACTERS = 4_096 + export const MobileAccessEndpointSchema = z .object({ id: z.string().min(1).max(128), @@ -12,7 +14,7 @@ export const MobileAccessEndpointSchema = z export const MobileRelayHostOverlaySchema = z .object({ v: z.literal(2), - hostId: z.string().min(1), + hostId: z.string().min(1).max(MOBILE_RELAY_HOST_OVERLAY_MAX_HOST_ID_CHARACTERS), endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16), relayHostId: z .string() diff --git a/mobile/src/transport/mobile-relay-invite-director.test.ts b/mobile/src/transport/mobile-relay-invite-director.test.ts index 04508024d07..3b0cab393df 100644 --- a/mobile/src/transport/mobile-relay-invite-director.test.ts +++ b/mobile/src/transport/mobile-relay-invite-director.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' +import { + MOBILE_RELAY_DIRECTOR_MAX_FRAME_BYTES, + resolvePairingInviteThroughDirector +} from './mobile-relay-invite-director' class FakeSocket { sent: string[] = [] @@ -77,4 +80,24 @@ describe('pairing invite director resolution', () => { await expect(resolving).rejects.toThrow(/not strictly newer/) }) + + it('rejects over-deep and oversized moves before materializing them', async () => { + const deepSocket = new FakeSocket() + const deep = resolvePairingInviteThroughDirector({ + relay, + createSocket: () => deepSocket as unknown as WebSocket + }) + deepSocket.onmessage?.({ data: `${'['.repeat(129)}0${']'.repeat(129)}` }) + await expect(deep).rejects.toThrow('invalid relay director move') + + const oversizedSocket = new FakeSocket() + const oversized = resolvePairingInviteThroughDirector({ + relay, + createSocket: () => oversizedSocket as unknown as WebSocket + }) + oversizedSocket.onmessage?.({ + data: 'x'.repeat(MOBILE_RELAY_DIRECTOR_MAX_FRAME_BYTES / 2 + 1) + }) + await expect(oversized).rejects.toThrow('invalid relay director move') + }) }) diff --git a/mobile/src/transport/mobile-relay-invite-director.ts b/mobile/src/transport/mobile-relay-invite-director.ts index 5dc83f526b6..1723f0e9f9e 100644 --- a/mobile/src/transport/mobile-relay-invite-director.ts +++ b/mobile/src/transport/mobile-relay-invite-director.ts @@ -1,5 +1,9 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol' +import { assertMobileInboundFrameSize } from './mobile-inbound-frame-queue' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' + +export const MOBILE_RELAY_DIRECTOR_MAX_FRAME_BYTES = 64 * 1024 export function resolvePairingInviteThroughDirector(args: { relay: PairingRelay @@ -32,7 +36,12 @@ export function resolvePairingInviteThroughDirector(args: { } let value: unknown try { - value = JSON.parse(event.data) + assertMobileInboundFrameSize( + event.data, + 'relay director move frame too large', + MOBILE_RELAY_DIRECTOR_MAX_FRAME_BYTES + ) + value = parseMobileJsonTextWithinLimits(event.data) } catch { finish(new Error('invalid relay director move')) return diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts index 5790eb456aa..b91ff031238 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -20,7 +20,11 @@ vi.mock('expo-secure-store', () => ({ vi.mock('expo-crypto', () => ({ getRandomBytes: vi.fn() })) vi.mock('react-native', () => ({ Platform: platform })) -import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { + MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS, + MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS, + createMobileRelayPairingJournal +} from './mobile-relay-pairing-journal' import { clearMobileRelayPairingJournal, loadMobileRelayPairingJournal, @@ -28,7 +32,14 @@ import { saveMobileRelayPairingJournal, updateMobileRelayPairingJournal } from './mobile-relay-pairing-journal-store' -import type { PairingOffer } from './types' +import { + MOBILE_HOST_ID_MAX_CHARACTERS, + MOBILE_HOST_NAME_MAX_CHARACTERS, + PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, + PAIRING_ENDPOINT_MAX_CHARACTERS, + PAIRING_PUBLIC_KEY_MAX_CHARACTERS, + type PairingOffer +} from './types' const now = Date.UTC(2026, 6, 13) const offer = { @@ -94,6 +105,75 @@ describe('mobile relay pairing journal store', () => { await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) }) + it('round-trips exact persisted field limits and rejects one character more', async () => { + const base = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + now, + randomBytes: (length) => new Uint8Array(length).fill(length) + }) + const exact = { + metadata: { + ...base.metadata, + host: { + ...base.metadata.host, + id: 'i'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS), + name: 'n'.repeat(MOBILE_HOST_NAME_MAX_CHARACTERS), + endpoint: 'e'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS), + publicKeyB64: 'p'.repeat(PAIRING_PUBLIC_KEY_MAX_CHARACTERS) + } + }, + secrets: { + ...base.secrets, + deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS) + } + } + + await expect(saveMobileRelayPairingJournal(exact)).resolves.toBeUndefined() + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(exact) + await expect( + saveMobileRelayPairingJournal({ + ...exact, + secrets: { ...exact.secrets, deviceToken: `${exact.secrets.deviceToken}t` } + }) + ).rejects.toThrow() + }) + + it('repairs oversized metadata without parsing it', async () => { + metadataRaw = { + length: MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS + 1 + } as unknown as string + secretRaw = 'orphan' + const parse = vi.spyOn(JSON, 'parse') + + await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() + expect(parse).not.toHaveBeenCalled() + expect(metadataRaw).toBeNull() + expect(secretRaw).toBeNull() + parse.mockRestore() + }) + + it('repairs oversized secrets without parsing them', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(9) + }) + metadataRaw = JSON.stringify(journal.metadata) + secretRaw = { + length: MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS + 1 + } as unknown as string + const parse = vi.spyOn(JSON, 'parse') + + await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() + expect(parse).toHaveBeenCalledTimes(1) + expect(metadataRaw).toBeNull() + expect(secretRaw).toBeNull() + parse.mockRestore() + }) + it('records a provisional winner only for the active journal identity', async () => { const journal = createMobileRelayPairingJournal({ offer: offer as PairingOffer & { relay: NonNullable }, diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts index b7044830da5..9f5841e117a 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -2,11 +2,15 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { + MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS, + MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS, MobileRelayPairingJournalMetadataSchema, MobileRelayPairingJournalSecretsSchema, type MobileRelayPairingJournal, - type MobileRelayPairingJournalMetadata + type MobileRelayPairingJournalMetadata, + type MobileRelayPairingJournalSecrets } from './mobile-relay-pairing-journal' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1' const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1' @@ -24,6 +28,8 @@ export async function saveMobileRelayPairingJournal( if (metadata.journalId !== secrets.journalId) { throw new Error('mobile relay pairing journal identity mismatch') } + const serializedMetadata = serializeMetadata(metadata) + const serializedSecrets = serializeSecrets(secrets) const mutation = journalMutation.then(async () => { const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) const existing = existingRaw ? parseMetadata(existingRaw) : null @@ -38,8 +44,8 @@ export async function saveMobileRelayPairingJournal( // a new user-initiated scan may safely supersede a pre-authorization attempt. // Why: metadata-first makes a crash before the keychain write recover as // an incomplete journal, never as an untracked bearer secret. - await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata)) - await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS) + await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, serializedMetadata) + await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, serializedSecrets, KEYCHAIN_OPTIONS) }) journalMutation = mutation.catch(() => {}) return mutation @@ -100,7 +106,7 @@ export async function updateMobileRelayPairingJournal( if (next.journalId !== journalId) { throw new Error('mobile relay pairing journal identity mismatch') } - await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(next)) + await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, serializeMetadata(next)) }) journalMutation = mutation.catch(() => {}) return mutation @@ -121,23 +127,49 @@ export async function clearMobileRelayPairingJournal(journalId: string): Promise } function parseMetadata(raw: string): MobileRelayPairingJournalMetadata | null { + if (raw.length > MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS) { + return null + } try { - const result = MobileRelayPairingJournalMetadataSchema.safeParse(JSON.parse(raw)) + const result = MobileRelayPairingJournalMetadataSchema.safeParse( + parseMobileJsonTextWithinLimits(raw) + ) return result.success ? result.data : null } catch { return null } } -function parseSecrets(raw: string) { +function parseSecrets(raw: string): MobileRelayPairingJournalSecrets | null { + if (raw.length > MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS) { + return null + } try { - const result = MobileRelayPairingJournalSecretsSchema.safeParse(JSON.parse(raw)) + const result = MobileRelayPairingJournalSecretsSchema.safeParse( + parseMobileJsonTextWithinLimits(raw) + ) return result.success ? result.data : null } catch { return null } } +function serializeMetadata(metadata: MobileRelayPairingJournalMetadata): string { + const serialized = JSON.stringify(metadata) + if (serialized.length > MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS) { + throw new Error('mobile relay pairing metadata exceeds storage limit') + } + return serialized +} + +function serializeSecrets(secrets: MobileRelayPairingJournalSecrets): string { + const serialized = JSON.stringify(secrets) + if (serialized.length > MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS) { + throw new Error('mobile relay pairing secrets exceed storage limit') + } + return serialized +} + function requireNativeSecretStore(): void { if (Platform.OS === 'web') { throw new Error('Orca Relay pairing requires a native secret store') diff --git a/mobile/src/transport/mobile-relay-pairing-journal.ts b/mobile/src/transport/mobile-relay-pairing-journal.ts index 08b27ad7009..b2b0648b0c8 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal.ts @@ -1,11 +1,23 @@ import * as ExpoCrypto from 'expo-crypto' import { sha256 } from '@noble/hashes/sha256' import { z } from 'zod' -import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' +import { + PAIRING_RELAY_URL_MAX_CHARACTERS, + type PairingRelay +} from '../../../src/shared/mobile-relay-pairing-offer' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' -import type { PairingOffer } from './types' +import { + MOBILE_HOST_ID_MAX_CHARACTERS, + MOBILE_HOST_NAME_MAX_CHARACTERS, + PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, + PAIRING_ENDPOINT_MAX_CHARACTERS, + PAIRING_PUBLIC_KEY_MAX_CHARACTERS, + type PairingOffer +} from './types' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +export const MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS = 256 * 1024 +export const MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS = 128 * 1024 export const MobileRelayPairingJournalMetadataSchema = z .object({ @@ -14,18 +26,18 @@ export const MobileRelayPairingJournalMetadataSchema = z offerFingerprint: Base64Url32ByteSchema, host: z .object({ - id: z.string().min(1), - name: z.string().min(1), - endpoint: z.string().min(1), - publicKeyB64: z.string().min(1), + id: z.string().min(1).max(MOBILE_HOST_ID_MAX_CHARACTERS), + name: z.string().min(1).max(MOBILE_HOST_NAME_MAX_CHARACTERS), + endpoint: z.string().min(1).max(PAIRING_ENDPOINT_MAX_CHARACTERS), + publicKeyB64: z.string().min(1).max(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), lastConnected: z.number().int().nonnegative() }) .strict(), relay: z .object({ v: z.literal(1), - directorUrl: z.string().min(1), - cellUrl: z.string().min(1), + directorUrl: z.string().min(1).max(PAIRING_RELAY_URL_MAX_CHARACTERS), + cellUrl: z.string().min(1).max(PAIRING_RELAY_URL_MAX_CHARACTERS), assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), relayHostId: z.string().regex(/^[A-Za-z0-9_-]{16}$/), inviteExpiresAt: z.number().int().positive(), @@ -44,7 +56,7 @@ export const MobileRelayPairingJournalSecretsSchema = z .object({ v: z.literal(1), journalId: z.string().min(1).max(128), - deviceToken: z.string().min(1), + deviceToken: z.string().min(1).max(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), inviteToken: Base64Url32ByteSchema, pendingResumeToken: Base64Url32ByteSchema }) diff --git a/mobile/src/transport/mobile-relay-physical-client.test.ts b/mobile/src/transport/mobile-relay-physical-client.test.ts index e4aaa0fe4f8..34054a9e866 100644 --- a/mobile/src/transport/mobile-relay-physical-client.test.ts +++ b/mobile/src/transport/mobile-relay-physical-client.test.ts @@ -8,7 +8,8 @@ const fakes = vi.hoisted(() => ({ start: vi.fn(), handleMessage: vi.fn(), sendText: vi.fn(() => true), - dispose: vi.fn() + dispose: vi.fn(), + socketClosed: vi.fn() })) vi.mock('./mobile-e2ee-v2-client-session', () => ({ @@ -23,6 +24,7 @@ vi.mock('./mobile-e2ee-v2-physical-channel', () => ({ handleMessage = fakes.handleMessage sendText = fakes.sendText dispose = fakes.dispose + socketClosed = fakes.socketClosed } })) @@ -132,4 +134,21 @@ describe('mobile relay physical pairing client', () => { await expect(status).rejects.toEqual(new RelayOuterError(4404)) expect(fakes.start).not.toHaveBeenCalled() }) + + it('rejects relay hello JSON above the nesting cap', async () => { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + + socket.receive(`${'['.repeat(129)}0${']'.repeat(129)}`) + + await expect(status).rejects.toThrow('invalid relay hello JSON') + expect(fakes.start).not.toHaveBeenCalled() + expect(fakes.dispose).toHaveBeenCalledOnce() + }) }) diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index acf264b8c73..2334d79e62c 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -2,9 +2,15 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offe import { RelayPhoneHelloSchema } from '../../../src/shared/mobile-relay-phone-protocol' import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' +import { assertMobileInboundFrameSize } from './mobile-inbound-frame-queue' +import { stringifyMobileOutboundJson } from './mobile-outbound-json' import { isRpcResponse } from './rpc-response-shape' import type { RpcResponse } from './types' import { websocketPayloadToUint8 } from './websocket-payload-bytes' +import { + isMobileJsonStructureCapacityError, + parseMobileJsonTextWithinLimits +} from './mobile-json-text-admission' export { RelayOuterError } from './mobile-relay-e2ee-link' import { RelayOuterError } from './mobile-relay-e2ee-link' @@ -47,35 +53,44 @@ export function connectMobileRelayForPairing(args: { resolveAuthenticated = resolve rejectAuthenticated = reject }) - const channel = new MobileE2EEV2PhysicalChannel({ - session, - socket, - deviceToken: args.deviceToken, - decodeBinary: websocketPayloadToUint8, - onAuthenticated: () => { - authenticated = true - resolveAuthenticated() - }, - onText: (plaintext) => { - let value: unknown - try { - value = JSON.parse(plaintext) - } catch { - return - } - if (!isRpcResponse(value)) { - return - } - const request = pending.get(value.id) - if (request) { - clearTimeout(request.timer) - pending.delete(value.id) - request.resolve(value) - } - }, - onBinary: () => {}, - onError: fail - }) + let channel: MobileE2EEV2PhysicalChannel + try { + channel = new MobileE2EEV2PhysicalChannel({ + session, + socket, + deviceToken: args.deviceToken, + decodeBinary: websocketPayloadToUint8, + onAuthenticated: () => { + authenticated = true + resolveAuthenticated() + }, + onText: (plaintext) => { + let value: unknown + try { + value = parseMobileJsonTextWithinLimits(plaintext) + } catch (error) { + if (isMobileJsonStructureCapacityError(error)) { + throw error + } + return + } + if (!isRpcResponse(value)) { + return + } + const request = pending.get(value.id) + if (request) { + clearTimeout(request.timer) + pending.delete(value.id) + request.resolve(value) + } + }, + onBinary: () => {}, + onError: fail + }) + } catch (error) { + socket.close() + throw error + } socket.onopen = () => { socket.send( @@ -87,23 +102,26 @@ export function connectMobileRelayForPairing(args: { }) ) } - let inboundChain: Promise = Promise.resolve() socket.onmessage = (event) => { - inboundChain = inboundChain - .then(async () => { - if (closed) { - return - } - if (!outerReady) { - acceptRelayHello(event.data) - return - } - await channel.handleMessage(event.data) - }) - .catch((error: unknown) => fail(asError(error))) + if (closed) { + return + } + try { + if (!outerReady) { + assertMobileInboundFrameSize(event.data, 'relay hello frame too large') + acceptRelayHello(event.data) + return + } + void channel.handleMessage(event.data) + } catch (error) { + fail(asError(error)) + } } socket.onerror = () => fail(new Error('relay transport error')) - socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006)) + socket.onclose = (event) => { + channel.socketClosed() + fail(new RelayOuterError(event.code || 1006)) + } function acceptRelayHello(raw: unknown): void { if (typeof raw !== 'string') { @@ -111,7 +129,7 @@ export function connectMobileRelayForPairing(args: { } let value: unknown try { - value = JSON.parse(raw) + value = parseMobileJsonTextWithinLimits(raw) } catch { throw new Error('invalid relay hello JSON') } @@ -157,9 +175,21 @@ export function connectMobileRelayForPairing(args: { reject(new Error(`relay pairing RPC timed out: ${method}`)) }, requestTimeoutMs) pending.set(id, { resolve, reject, timer }) - if ( - !channel.sendText(JSON.stringify({ id, deviceToken: args.deviceToken, method, params })) - ) { + let serialized: string + try { + serialized = stringifyMobileOutboundJson({ + id, + deviceToken: args.deviceToken, + method, + params + }) + } catch { + clearTimeout(timer) + pending.delete(id) + reject(new Error('relay RPC request is too large')) + return + } + if (!channel.sendText(serialized)) { clearTimeout(timer) pending.delete(id) reject(new Error('relay E2EE channel not ready')) diff --git a/mobile/src/transport/mobile-relay-resume-director.test.ts b/mobile/src/transport/mobile-relay-resume-director.test.ts index 426ff89ba07..7ea28a368e2 100644 --- a/mobile/src/transport/mobile-relay-resume-director.test.ts +++ b/mobile/src/transport/mobile-relay-resume-director.test.ts @@ -63,4 +63,27 @@ describe('mobile relay resume director', () => { resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: oversized }) ).rejects.toThrow(/too large/) }) + + it('cancels a streamed response once it crosses the mobile limit', async () => { + let cancelled = false + const streamed = vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(16 * 1024)) + controller.enqueue(new Uint8Array([1])) + }, + cancel() { + cancelled = true + } + }) + ) + ) + + await expect( + resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: streamed }) + ).rejects.toThrow(/too large/) + expect(cancelled).toBe(true) + }) }) diff --git a/mobile/src/transport/mobile-relay-resume-director.ts b/mobile/src/transport/mobile-relay-resume-director.ts index 86fd283b9a5..1679de56e6f 100644 --- a/mobile/src/transport/mobile-relay-resume-director.ts +++ b/mobile/src/transport/mobile-relay-resume-director.ts @@ -1,5 +1,10 @@ import { z } from 'zod' import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' +import { + FetchResponseBodyTooLargeError, + readFetchResponseTextWithinLimit +} from '../../../src/shared/fetch-response-body' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const MAX_RESPONSE_BYTES = 16 * 1024 const ResolveResponseSchema = z @@ -34,15 +39,16 @@ export async function resolveMobileRelayEndpoint(args: { if (!response.ok) { throw new Error(`relay director resolve failed (${response.status})`) } - const declaredLength = Number(response.headers.get('content-length') ?? 0) - if (declaredLength > MAX_RESPONSE_BYTES) { - throw new Error('relay director resolve response too large') + let raw: string + try { + raw = await readFetchResponseTextWithinLimit(response, MAX_RESPONSE_BYTES) + } catch (error) { + if (error instanceof FetchResponseBodyTooLargeError) { + throw new Error('relay director resolve response too large') + } + throw error } - const raw = await response.text() - if (new TextEncoder().encode(raw).byteLength > MAX_RESPONSE_BYTES) { - throw new Error('relay director resolve response too large') - } - const resolved = ResolveResponseSchema.parse(JSON.parse(raw) as unknown) + const resolved = ResolveResponseSchema.parse(parseMobileJsonTextWithinLimits(raw)) return { ...args.relay, cellUrl: resolved.cellUrl, diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index a0e71a85c1b..e3a3529bd8f 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -7,9 +7,14 @@ import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link' import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { stringifyMobileOutboundJson } from './mobile-outbound-json' import { isRpcResponse } from './rpc-response-shape' import type { RpcClient } from './rpc-client' import type { ConnectionState, RpcResponse } from './types' +import { + isMobileJsonStructureCapacityError, + parseMobileJsonTextWithinLimits +} from './mobile-json-text-admission' type PendingRequest = { resolve: (response: RpcResponse) => void @@ -162,14 +167,23 @@ export function connectMobileRelayRpcSession(args: { } function sendFrame(request: { id: string; method: string; params?: unknown }): boolean { - return link.sendText(JSON.stringify({ ...request, deviceToken: args.deviceToken })) + try { + return link.sendText( + stringifyMobileOutboundJson({ ...request, deviceToken: args.deviceToken }) + ) + } catch { + return false + } } function handleText(plaintext: string): void { let value: unknown try { - value = JSON.parse(plaintext) - } catch { + value = parseMobileJsonTextWithinLimits(plaintext) + } catch (error) { + if (isMobileJsonStructureCapacityError(error)) { + throw error + } return } if (!isRpcResponse(value)) { diff --git a/mobile/src/transport/mobile-relay-rpc-streams.test.ts b/mobile/src/transport/mobile-relay-rpc-streams.test.ts deleted file mode 100644 index b0e59c6bf5b..00000000000 --- a/mobile/src/transport/mobile-relay-rpc-streams.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcFailure } from './types' -import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams' - -function rpcFailure(id: string): RpcFailure { - return { - id, - ok: false, - error: { code: 'unsupported', message: 'Unknown method' }, - _meta: { runtimeId: 'runtime-1' } - } -} - -describe('MobileRelayRpcStreams failure parity', () => { - it('emits an RPC failure exactly once before removing the stream', async () => { - const listener = vi.fn() - const sendFrame = vi.fn(() => true) - const streams = new MobileRelayRpcStreams({ - nextId: () => 'stream-1', - sendFrame, - waitForConnected: async () => {} - }) - const cancel = streams.subscribe( - 'session.tabs.subscribe', - { worktree: 'id:worktree-1' }, - listener - ) - await Promise.resolve() - - expect(streams.handleResponse(rpcFailure('stream-1'))).toBe(true) - expect(listener).toHaveBeenCalledExactlyOnceWith({ - type: 'error', - message: 'Unknown method', - error: { code: 'unsupported', message: 'Unknown method' } - }) - expect(streams.handleResponse(rpcFailure('stream-1'))).toBe(false) - cancel() - expect(sendFrame).toHaveBeenCalledTimes(1) - }) - - it('emits a connection-wait rejection exactly once without sending or cancelling', async () => { - const listener = vi.fn() - const sendFrame = vi.fn(() => true) - const waitError = new Error('relay session closed') - const streams = new MobileRelayRpcStreams({ - nextId: () => 'stream-1', - sendFrame, - waitForConnected: () => Promise.reject(waitError) - }) - const cancel = streams.subscribe( - 'session.tabs.subscribe', - { worktree: 'id:worktree-1' }, - listener - ) - await Promise.resolve() - await Promise.resolve() - - expect(listener).toHaveBeenCalledExactlyOnceWith({ - type: 'error', - message: 'relay session closed', - error: waitError - }) - expect(streams.handleResponse(rpcFailure('stream-1'))).toBe(false) - cancel() - expect(sendFrame).not.toHaveBeenCalled() - }) - - it('emits a send failure exactly once and fences cancellation and late frames', async () => { - const listener = vi.fn() - const sendFrame = vi.fn(() => false) - const streams = new MobileRelayRpcStreams({ - nextId: () => 'stream-1', - sendFrame, - waitForConnected: async () => {} - }) - const cancel = streams.subscribe( - 'session.tabs.subscribe', - { worktree: 'id:worktree-1' }, - listener - ) - await Promise.resolve() - await Promise.resolve() - - expect(listener).toHaveBeenCalledExactlyOnceWith({ - type: 'error', - message: 'Connection interrupted', - error: undefined - }) - cancel() - expect(streams.handleResponse(rpcFailure('stream-1'))).toBe(false) - expect(sendFrame).toHaveBeenCalledTimes(1) - }) - - it('does not emit a failure after the caller cancels a queued stream', async () => { - const listener = vi.fn() - const sendFrame = vi.fn(() => true) - const connection = Promise.withResolvers() - const streams = new MobileRelayRpcStreams({ - nextId: () => 'stream-1', - sendFrame, - waitForConnected: () => connection.promise - }) - const cancel = streams.subscribe( - 'session.tabs.subscribe', - { worktree: 'id:worktree-1' }, - listener - ) - cancel() - connection.reject(new Error('late failure')) - await Promise.resolve() - await Promise.resolve() - - expect(listener).not.toHaveBeenCalled() - expect(sendFrame).not.toHaveBeenCalled() - }) - - it('does not send or emit after session clear settles a connection wait', async () => { - const listener = vi.fn() - const sendFrame = vi.fn(() => true) - const connection = Promise.withResolvers() - const streams = new MobileRelayRpcStreams({ - nextId: () => 'stream-1', - sendFrame, - waitForConnected: () => connection.promise - }) - streams.subscribe('session.tabs.subscribe', { worktree: 'id:worktree-1' }, listener) - streams.clear() - connection.resolve() - await Promise.resolve() - await Promise.resolve() - - expect(listener).not.toHaveBeenCalled() - expect(sendFrame).not.toHaveBeenCalled() - }) - - it('removes a failed stream even when its listener throws', async () => { - const listener = vi.fn(() => { - throw new Error('listener failed') - }) - const streams = new MobileRelayRpcStreams({ - nextId: () => 'stream-1', - sendFrame: () => true, - waitForConnected: async () => {} - }) - streams.subscribe('session.tabs.subscribe', { worktree: 'id:worktree-1' }, listener) - await Promise.resolve() - - expect(() => streams.handleResponse(rpcFailure('stream-1'))).toThrow('listener failed') - expect(streams.handleResponse(rpcFailure('stream-1'))).toBe(false) - expect(listener).toHaveBeenCalledTimes(1) - }) -}) diff --git a/mobile/src/transport/mobile-relay-rpc-streams.ts b/mobile/src/transport/mobile-relay-rpc-streams.ts index 7c485b27e02..552880e163a 100644 --- a/mobile/src/transport/mobile-relay-rpc-streams.ts +++ b/mobile/src/transport/mobile-relay-rpc-streams.ts @@ -58,13 +58,10 @@ export class MobileRelayRpcStreams { .waitForConnected() .then(() => { if (!stream.cancelled && !this.options.sendFrame({ id, method, params: stream.params })) { - this.fail(id, stream, 'Connection interrupted') + this.remove(id) } }) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : 'Connection interrupted' - this.fail(id, stream, message, error) - }) + .catch(() => this.remove(id)) return () => this.cancel(id) } @@ -78,7 +75,7 @@ export class MobileRelayRpcStreams { return false } if (!response.ok) { - this.fail(response.id, stream, response.error.message, response.error) + this.remove(response.id) return true } const result = (response as RpcSuccess).result @@ -120,9 +117,6 @@ export class MobileRelayRpcStreams { } clear(): void { - for (const stream of this.streams.values()) { - stream.cancelled = true - } this.streams.clear() this.terminalListeners.clear() this.terminalSnapshots.clear() @@ -168,15 +162,4 @@ export class MobileRelayRpcStreams { } this.streams.delete(id) } - - private fail(id: string, stream: StreamRecord, message: string, error?: unknown): void { - if (stream.cancelled || this.streams.get(id) !== stream) { - return - } - try { - stream.listener({ type: 'error', message, error }) - } finally { - this.remove(id) - } - } } diff --git a/mobile/src/transport/mobile-rpc-binary-frame-handler.ts b/mobile/src/transport/mobile-rpc-binary-frame-handler.ts new file mode 100644 index 00000000000..15e9c24061d --- /dev/null +++ b/mobile/src/transport/mobile-rpc-binary-frame-handler.ts @@ -0,0 +1,18 @@ +import { decryptBytes } from './e2ee' +import { websocketPayloadToUint8 } from './websocket-payload-bytes' + +export async function handleMobileRpcSocketBinaryMessage(args: { + rawData: unknown + key: Uint8Array + isCurrent: () => boolean + onFrame: (plaintext: Uint8Array) => void +}): Promise { + const bytes = await websocketPayloadToUint8(args.rawData) + if (!args.isCurrent() || !bytes) { + return + } + const plaintextBytes = decryptBytes(bytes, args.key) + if (plaintextBytes) { + args.onFrame(plaintextBytes) + } +} diff --git a/mobile/src/transport/pairing.test.ts b/mobile/src/transport/pairing.test.ts index 9b7b7514369..5f14f925d6f 100644 --- a/mobile/src/transport/pairing.test.ts +++ b/mobile/src/transport/pairing.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { decodePairingUrl, extractPairingCodeFromUrl, parsePairingCode } from './pairing' -import type { PairingOffer } from './types' +import { + PAIRING_CODE_MAX_CHARACTERS, + PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, + PairingOfferSchema, + type PairingOffer +} from './types' const offer: PairingOffer = { v: 2, @@ -72,4 +77,31 @@ describe('pairing deep links', () => { expect(parsePairingCode(code)).toEqual(proxiedOffer) }) + + it('rejects an oversized code before base64 decoding', () => { + const decode = vi.fn(() => { + throw new Error('must not decode') + }) + vi.stubGlobal('atob', decode) + const oversized = 'A'.repeat(PAIRING_CODE_MAX_CHARACTERS + 1) + + expect(parsePairingCode(oversized)).toBeNull() + expect(decodePairingUrl(`orca://pair?code=${oversized}`)).toBeNull() + expect(decode).not.toHaveBeenCalled() + }) + + it('accepts the device-token limit and rejects one character more', () => { + expect( + PairingOfferSchema.safeParse({ + ...offer, + deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS) + }).success + ).toBe(true) + expect( + PairingOfferSchema.safeParse({ + ...offer, + deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS + 1) + }).success + ).toBe(false) + }) }) diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts index d330f1834fa..0322c107b4b 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -1,4 +1,10 @@ -import { PairingOfferSchema, type PairingOffer } from './types' +import { + PAIRING_CODE_MAX_CHARACTERS, + PAIRING_INPUT_MAX_CHARACTERS, + PairingOfferSchema, + type PairingOffer +} from './types' +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' // Why: this file mirrors src/shared/pairing.ts (which is covered by CI // vitest) but uses atob/btoa because Metro/Hermes don't ship Node's @@ -6,6 +12,9 @@ import { PairingOfferSchema, type PairingOffer } from './types' // the other. export function decodePairingUrl(url: string): PairingOffer | null { + if (url.length > PAIRING_INPUT_MAX_CHARACTERS) { + return null + } try { const code = extractPairingCodeFromUrl(url) if (!code) { @@ -21,6 +30,9 @@ export function decodePairingUrl(url: string): PairingOffer | null { // extraction here makes QR scan, paste, and external deep-link flows // accept the same URL shapes. export function extractPairingCodeFromUrl(url: string): string | null { + if (url.length > PAIRING_INPUT_MAX_CHARACTERS) { + return null + } const trimmed = url.trim() const match = /^orca:\/\/([^/?#]*)([^?#]*)?/i.exec(trimmed) if (!match) { @@ -53,6 +65,9 @@ export function extractPairingCodeFromUrl(url: string): string | null { // string so the paste-pair flow can take whichever the user actually // copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { + if (input.length > PAIRING_INPUT_MAX_CHARACTERS) { + return null + } const trimmed = input.trim() if (!trimmed) { return null @@ -68,11 +83,18 @@ export function parsePairingCode(input: string): PairingOffer | null { } function decodePairingBase64(base64url: string): PairingOffer { + if ( + base64url.length === 0 || + base64url.length > PAIRING_CODE_MAX_CHARACTERS || + !/^[A-Za-z0-9+/_-]+={0,2}$/.test(base64url) + ) { + throw new Error('Invalid pairing code') + } // Why: desktop intentionally strips base64 padding from QR payloads. Some // mobile JS runtimes reject unpadded atob input, so restore it before decode. const base64 = padBase64(base64url.replace(/-/g, '+').replace(/_/g, '/')) const json = atob(base64) - return PairingOfferSchema.parse(JSON.parse(json)) + return PairingOfferSchema.parse(parseMobileJsonTextWithinLimits(json)) } function padBase64(base64: string): string { diff --git a/mobile/src/transport/redacted-websocket-endpoint.ts b/mobile/src/transport/redacted-websocket-endpoint.ts new file mode 100644 index 00000000000..b19431099ee --- /dev/null +++ b/mobile/src/transport/redacted-websocket-endpoint.ts @@ -0,0 +1,9 @@ +// Why: keep device tokens and full URLs out of connection logs. +export function redactedWebSocketEndpoint(endpoint: string): string { + try { + const url = new URL(endpoint) + return (url.protocol === 'ws:' || url.protocol === 'wss:') && url.host ? url.host : 'unknown' + } catch { + return 'unknown' + } +} diff --git a/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts b/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts index 216fd2aec56..8030dec8ce6 100644 --- a/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts +++ b/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts @@ -32,4 +32,92 @@ describe('handleTerminalBinaryFrame', () => { expect(recordValidatedInboundTraffic).toHaveBeenCalledTimes(1) expect(listener).toHaveBeenCalledWith({ type: 'metadata', streamId: 42, cwd: '/repo/src' }) }) + + it('assembles an ordinary snapshot without changing its payload', () => { + const listener = vi.fn() + const terminalSnapshots = new Map() + const options = { + terminalSnapshots, + getListener: () => listener, + recordValidatedInboundTraffic: vi.fn() + } + + handleTerminalBinaryFrame( + encodeFrame(TerminalStreamOpcode.SnapshotStart, 7, { kind: 'scrollback' }), + options + ) + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, 'hello '), options) + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, 'world'), options) + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotEnd, 7, ''), options) + + expect(listener).toHaveBeenCalledWith({ + kind: 'scrollback', + serialized: 'hello world', + streamId: 7, + type: 'scrollback' + }) + expect(terminalSnapshots.size).toBe(0) + }) + + it('drops cumulative snapshot data at the configured byte bound', () => { + const listener = vi.fn() + const terminalSnapshots = new Map() + const options = { + terminalSnapshots, + getListener: () => listener, + recordValidatedInboundTraffic: vi.fn(), + maxSnapshotBytes: 5 + } + + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotStart, 7, {}), options) + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, '12345'), options) + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, '6'), options) + + expect(terminalSnapshots.size).toBe(0) + expect(listener).toHaveBeenLastCalledWith({ + message: 'Terminal snapshot exceeded size limit.', + streamId: 7, + type: 'error' + }) + }) + + it('bounds tiny snapshot chunks independently of byte size', () => { + const listener = vi.fn() + const terminalSnapshots = new Map() + const options = { + terminalSnapshots, + getListener: () => listener, + recordValidatedInboundTraffic: vi.fn(), + maxSnapshotChunks: 2 + } + + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotStart, 7, {}), options) + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, ''), options) + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, ''), options) + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, ''), options) + + expect(terminalSnapshots.size).toBe(0) + expect(listener).toHaveBeenLastCalledWith({ + message: 'Terminal snapshot exceeded size limit.', + streamId: 7, + type: 'error' + }) + }) + + it('rejects a terminal frame before decoding an oversized payload', () => { + const listener = vi.fn() + + handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.Metadata, 42, { value: 'large' }), { + terminalSnapshots: new Map(), + getListener: () => listener, + recordValidatedInboundTraffic: vi.fn(), + maxFramePayloadBytes: 4 + }) + + expect(listener).toHaveBeenCalledWith({ + message: 'Terminal stream frame exceeded size limit.', + streamId: 42, + type: 'error' + }) + }) }) diff --git a/mobile/src/transport/rpc-client-terminal-binary-frame.ts b/mobile/src/transport/rpc-client-terminal-binary-frame.ts index ee7da35383e..562362b7e3f 100644 --- a/mobile/src/transport/rpc-client-terminal-binary-frame.ts +++ b/mobile/src/transport/rpc-client-terminal-binary-frame.ts @@ -9,14 +9,44 @@ export type TerminalSnapshotState = { streamId: number meta: Record chunks: string[] + bytes: number } +export const MOBILE_TERMINAL_STREAM_FRAME_MAX_PAYLOAD_BYTES = 256 * 1024 +export const MOBILE_TERMINAL_SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024 +export const MOBILE_TERMINAL_SNAPSHOT_MAX_AGGREGATE_BYTES = 16 * 1024 * 1024 +export const MOBILE_TERMINAL_SNAPSHOT_MAX_ACTIVE = 16 +export const MOBILE_TERMINAL_SNAPSHOT_MAX_CHUNKS = 1_024 + type StreamingListener = (result: unknown) => void type TerminalBinaryFrameOptions = { terminalSnapshots: Map getListener: (streamId: number) => StreamingListener | undefined recordValidatedInboundTraffic: () => void + maxFramePayloadBytes?: number + maxSnapshotBytes?: number + maxSnapshotAggregateBytes?: number + maxActiveSnapshots?: number + maxSnapshotChunks?: number +} + +function retainedSnapshotBytes(snapshots: Map): number { + let bytes = 0 + for (const snapshot of snapshots.values()) { + bytes += snapshot.bytes + } + return bytes +} + +function rejectSnapshot( + options: TerminalBinaryFrameOptions, + listener: StreamingListener, + streamId: number, + message: string +): void { + options.terminalSnapshots.delete(streamId) + listener({ type: 'error', streamId, message }) } export function handleTerminalBinaryFrame( @@ -32,6 +62,12 @@ export function handleTerminalBinaryFrame( options.recordValidatedInboundTraffic() return } + const maxFramePayloadBytes = + options.maxFramePayloadBytes ?? MOBILE_TERMINAL_STREAM_FRAME_MAX_PAYLOAD_BYTES + if (frame.payload.byteLength > maxFramePayloadBytes) { + rejectSnapshot(options, listener, frame.streamId, 'Terminal stream frame exceeded size limit.') + return + } if (frame.opcode === TerminalStreamOpcode.Output) { options.recordValidatedInboundTraffic() listener({ @@ -46,11 +82,20 @@ export function handleTerminalBinaryFrame( if (!meta) { return } + const maxActiveSnapshots = options.maxActiveSnapshots ?? MOBILE_TERMINAL_SNAPSHOT_MAX_ACTIVE + if ( + !options.terminalSnapshots.has(frame.streamId) && + options.terminalSnapshots.size >= maxActiveSnapshots + ) { + rejectSnapshot(options, listener, frame.streamId, 'Too many terminal snapshots are active.') + return + } options.recordValidatedInboundTraffic() options.terminalSnapshots.set(frame.streamId, { streamId: frame.streamId, meta, - chunks: [] + chunks: [], + bytes: 0 }) return } @@ -60,7 +105,21 @@ export function handleTerminalBinaryFrame( if (!snapshot) { return } + const maxSnapshotBytes = options.maxSnapshotBytes ?? MOBILE_TERMINAL_SNAPSHOT_MAX_BYTES + const maxSnapshotAggregateBytes = + options.maxSnapshotAggregateBytes ?? MOBILE_TERMINAL_SNAPSHOT_MAX_AGGREGATE_BYTES + const maxSnapshotChunks = options.maxSnapshotChunks ?? MOBILE_TERMINAL_SNAPSHOT_MAX_CHUNKS + if ( + snapshot.chunks.length >= maxSnapshotChunks || + snapshot.bytes + frame.payload.byteLength > maxSnapshotBytes || + retainedSnapshotBytes(options.terminalSnapshots) + frame.payload.byteLength > + maxSnapshotAggregateBytes + ) { + rejectSnapshot(options, listener, frame.streamId, 'Terminal snapshot exceeded size limit.') + return + } snapshot.chunks.push(decodeTerminalStreamText(frame.payload)) + snapshot.bytes += frame.payload.byteLength return } if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) { diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index b2adb18c4ce..73302fb8b5d 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -153,6 +153,25 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) + it('keeps WebSocket credentials and URL paths out of retained connection logs', () => { + const details: Array = [] + const client = connect( + 'wss://user:password@desktop.example:8443/relay?access_token=secret', + 'token', + 'server-key', + { + onLog: (entry) => details.push(entry.detail) + } + ) + + expect(details).toContain('desktop.example:8443') + expect(details.join('\n')).not.toContain('password') + expect(details.join('\n')).not.toContain('access_token') + expect(details.join('\n')).not.toContain('/relay') + + client.close() + }) + it('ignores stale socket opens after reconnect swaps in a new socket', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const firstSocket = mockSockets[0]! diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 9b0b6246f1c..54d4c4cd9e3 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -10,9 +10,7 @@ import { deriveSharedKey, publicKeyFromBase64, publicKeyToBase64, - encrypt, - decrypt, - decryptBytes + decrypt } from './e2ee' import { handleTerminalBinaryFrame, @@ -30,7 +28,13 @@ import { import { describeSocketEvent } from './socket-event-debug' import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { isRpcResponse } from './rpc-response-shape' -import { websocketPayloadToUint8 } from './websocket-payload-bytes' +import { createMobileInboundFrameQueue } from './mobile-inbound-frame-queue' +import { createMobileDirectRpcOutbound } from './mobile-direct-rpc-outbound' +import { createMobileDirectRpcSender } from './mobile-direct-rpc-sender' +import { handleMobileRpcSocketBinaryMessage } from './mobile-rpc-binary-frame-handler' +import { processMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget' +import { redactedWebSocketEndpoint } from './redacted-websocket-endpoint' +import { tryParseMobileJsonTextWithinLimits } from './mobile-json-text-admission' type PendingRequest = { resolve: (response: RpcResponse) => void @@ -142,6 +146,7 @@ export function connect( }) } let ws: WebSocket | null = null + let outbound: ReturnType | null = null let state: ConnectionState = 'disconnected' let requestCounter = 0 let reconnectAttempt = 0 @@ -204,7 +209,7 @@ export function connect( to: next, dweltMs: dwelt, attempt: reconnectAttempt, - endpoint: redactedEndpoint(endpoint) + endpoint: redactedWebSocketEndpoint(endpoint) }) if (next === 'connected') { lastConnectedAt = Date.now() @@ -226,16 +231,6 @@ export function connect( } } - // Why: keep device tokens / full URLs out of log scrolls — truncate to host:port. - function redactedEndpoint(ep: string): string { - try { - const m = ep.match(/^wss?:\/\/([^/]+)/i) - return m ? m[1] : 'unknown' - } catch { - return 'unknown' - } - } - function waitForConnected(timeoutMs?: number): Promise { if (state === 'connected') { return Promise.resolve() @@ -270,6 +265,11 @@ export function connect( return `rpc-${++requestCounter}-${Date.now()}` } + function disposeActiveOutbound(): void { + outbound?.dispose() + outbound = null + } + function openConnection() { if (intentionallyClosed) { return @@ -279,7 +279,7 @@ export function connect( wsConstructionCounter++ console.log('[net] openConnection', { attempt: reconnectAttempt, - endpoint: redactedEndpoint(endpoint), + endpoint: redactedWebSocketEndpoint(endpoint), // Why: diagnostic for RN/OkHttp pool corruption — high wsCount + repeated 1006 closes means process-state stuck. wsCount: wsConstructionCounter, msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null, @@ -293,11 +293,37 @@ export function connect( emitLog( 'info', reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket', - endpoint + redactedWebSocketEndpoint(endpoint) ) + if (!processMobileOutboundMemoryBudget.canRegisterBufferedAmount()) { + emitLog('error', 'WebSocket reconnect deferred', 'Retired socket buffers are still draining') + setState('reconnecting') + scheduleReconnect() + return + } + ws = new WebSocket(endpoint) const openingWs = ws + const closeForOverload = (direction: 'Inbound' | 'Outbound', detail: string): void => { + emitLog('error', `${direction} WebSocket overload`, detail) + openingWs.close() + if (ws === openingWs) { + handleSocketClosed(openingWs) + } + } + const openingOutbound = createMobileDirectRpcOutbound({ + socket: openingWs, + isActive: () => ws === openingWs, + onOverflow: () => closeForOverload('Outbound', 'Mobile RPC outbound buffer overflow') + }) + outbound = openingOutbound + const inboundQueue = createMobileInboundFrameQueue({ + process: handleSocketMessage, + onError: (error) => closeForOverload('Inbound', error.message), + overflowMessage: 'Mobile RPC inbound buffer overflow', + frameTooLargeMessage: 'Mobile RPC inbound frame too large' + }) const ignoreStaleSocketEvent = (eventName: string): boolean => { if (ws === openingWs) { return false @@ -373,10 +399,10 @@ export function connect( if (ignoreStaleSocketEvent('message')) { return } - void handleSocketMessage(event.data) + void inboundQueue.enqueue(event.data) } - async function handleSocketMessage(rawData: unknown) { + function handleSocketMessage(rawData: unknown): Promise | void { lastInboundAt = Date.now() const raw = typeof rawData === 'string' ? rawData : null @@ -385,15 +411,11 @@ export function connect( if (raw === null) { return } - try { - const msg = JSON.parse(raw) - if (msg.type === 'e2ee_ready') { - emitLog('success', 'Received e2ee_ready', 'Sending device token') - sendEncrypted({ type: 'e2ee_auth', deviceToken }) - return - } - } catch { - // Not plaintext JSON — fall through and try encrypted handshake messages. + const plaintextControl = tryParseMobileJsonTextWithinLimits>(raw) + if (plaintextControl?.type === 'e2ee_ready') { + emitLog('success', 'Received e2ee_ready', 'Sending device token') + sendEncrypted({ type: 'e2ee_auth', deviceToken }) + return } if (!sharedKey || sharedKey.length !== 32) { @@ -405,9 +427,10 @@ export function connect( return } - try { - const msg = JSON.parse(plaintext) + const msg = tryParseMobileJsonTextWithinLimits>(plaintext) + if (msg) { if (msg.type === 'e2ee_authenticated') { + openingOutbound.acknowledgeAuthentication() if (handshakeTimer) { clearTimeout(handshakeTimer) handshakeTimer = null @@ -441,7 +464,11 @@ export function connect( removeStreamListener(id) } } - } else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) { + } else if ( + msg.type === 'e2ee_error' || + (!msg.ok && (msg.error as { code?: unknown } | undefined)?.code === 'unauthorized') + ) { + openingOutbound.acknowledgeAuthentication() console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error }) if (handshakeTimer) { clearTimeout(handshakeTimer) @@ -449,8 +476,6 @@ export function connect( } handleAuthRejection('Unauthorized — pairing may be revoked') } - } catch { - // Not JSON — ignore during handshake. } return } @@ -461,19 +486,12 @@ export function connect( } if (raw === null) { - const bytes = await websocketPayloadToUint8(rawData) - if (ws !== openingWs) { - return - } - if (!bytes) { - return - } - const plaintextBytes = decryptBytes(bytes, sharedKey) - if (!plaintextBytes) { - return - } - handleBinaryFrame(plaintextBytes) - return + return handleMobileRpcSocketBinaryMessage({ + rawData, + key: sharedKey, + isCurrent: () => ws === openingWs, + onFrame: handleBinaryFrame + }) } const plaintext = decrypt(raw, sharedKey) @@ -481,15 +499,14 @@ export function connect( return } - let response: unknown - try { - response = JSON.parse(plaintext) - } catch { + const response = tryParseMobileJsonTextWithinLimits(plaintext) + if (response === null) { return } if (!isRpcResponse(response)) { return } + openingOutbound.acknowledge(response.id) recordValidatedInboundTraffic() // Why: a mid-session unauthorized may be transient (issue #5200) — handleAuthRejection retries before latching auth-failed. @@ -580,6 +597,11 @@ export function connect( } ws.onclose = (event) => { + inboundQueue.dispose() + openingOutbound.socketClosed() + if (outbound === openingOutbound) { + disposeActiveOutbound() + } const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined const closeAt = Date.now() // Why: time-since-construct classifies the failure — instant close = RST/unreachable, slow = SYN timeout/packet loss. @@ -596,7 +618,7 @@ export function connect( state, attempt: reconnectAttempt, intentionallyClosed, - endpoint: redactedEndpoint(endpoint), + endpoint: redactedWebSocketEndpoint(endpoint), constructToCloseMs, aliveMs, inboundIdleMs, @@ -637,6 +659,7 @@ export function connect( return } clearConnectTimer() + disposeActiveOutbound() ws = null sharedKey = null activeBrowserScreencastRequestId = null @@ -684,7 +707,7 @@ export function connect( console.log('[net] auth rejected — retrying handshake', { attempt: authRejectionCount, budget: AUTH_RETRY_BUDGET, - endpoint: redactedEndpoint(endpoint) + endpoint: redactedWebSocketEndpoint(endpoint) }) emitLog( 'warn', @@ -693,6 +716,7 @@ export function connect( ) // Why: close without setting intentionallyClosed so handleSocketClosed routes to reconnect and retries the handshake. const closing = ws + disposeActiveOutbound() ws = null sharedKey = null // Why: close cleanup stale-bails here, so mark active streams for replay. @@ -707,9 +731,10 @@ export function connect( } console.log('[net] auth rejected — budget exhausted, latching auth-failed', { attempt: authRejectionCount, - endpoint: redactedEndpoint(endpoint) + endpoint: redactedWebSocketEndpoint(endpoint) }) intentionallyClosed = true + disposeActiveOutbound() ws?.close() ws = null setState('auth-failed') @@ -929,26 +954,13 @@ export function connect( stream.onBinaryFrame?.(frame) } - function sendEncrypted(request: unknown): boolean { - if (ws && ws.readyState === WebSocket.OPEN && sharedKey) { - ws.send(encrypt(JSON.stringify(request), sharedKey)) - return true - } - console.log('[net] sendEncrypted FAILED — channel not ready', { - hasWs: !!ws, - readyState: ws?.readyState, - hasKey: !!sharedKey, - state - }) - // Why: RN can drop onclose, leaving state 'connected' over a dead socket; force reconnect or every send silently fails forever. - if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) { - console.log('[net] sendEncrypted detected ws desync — forcing reconnect', { - readyState: ws.readyState - }) - handleSocketClosed(ws, { timedOut: false }) - } - return false - } + const sendEncrypted = createMobileDirectRpcSender({ + getOutbound: () => outbound, + getSharedKey: () => sharedKey, + getSocket: () => ws, + getState: () => state, + onSocketDesync: (socket) => handleSocketClosed(socket, { timedOut: false }) + }) function sendBrowserScreencastUnsubscribe(subscriptionId: string): void { sendEncrypted({ @@ -1159,6 +1171,7 @@ export function connect( handshakeTimer = null } stopActivityProbe() + disposeActiveOutbound() if (ws) { ws.close() ws = null diff --git a/mobile/src/transport/runtime-capability-probe.test.ts b/mobile/src/transport/runtime-capability-probe.test.ts index 2272c25610f..cffc97f30d1 100644 --- a/mobile/src/transport/runtime-capability-probe.test.ts +++ b/mobile/src/transport/runtime-capability-probe.test.ts @@ -48,54 +48,6 @@ describe('startRuntimeCapabilityProbe', () => { cancel() }) - it('treats malformed capabilities as unsupported', async () => { - const response: RpcResponse = { - ok: true, - id: '1', - result: { capabilities: 'a.v1' }, - _meta: { runtimeId: 'r1' } - } - const { client, calls } = makeClient([response]) - const seen: (readonly string[])[] = [] - const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities)) - await flushMicrotasks() - expect(seen).toEqual([[]]) - expect(calls()).toBe(1) - cancel() - }) - - it('rejects capability arrays containing non-string values', async () => { - const response: RpcResponse = { - ok: true, - id: '1', - result: { capabilities: ['a.v1', 42] }, - _meta: { runtimeId: 'r1' } - } - const { client, calls } = makeClient([response]) - const seen: (readonly string[])[] = [] - const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities)) - await flushMicrotasks() - expect(seen).toEqual([[]]) - expect(calls()).toBe(1) - cancel() - }) - - it('treats a malformed status result as unsupported', async () => { - const response: RpcResponse = { - ok: true, - id: '1', - result: null, - _meta: { runtimeId: 'r1' } - } - const { client, calls } = makeClient([response]) - const seen: (readonly string[])[] = [] - const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities)) - await flushMicrotasks() - expect(seen).toEqual([[]]) - expect(calls()).toBe(1) - cancel() - }) - it('retries promptly after a logical-client cutover rejection', async () => { const { client, calls } = makeClient([new LogicalClientCutoverError(), ok(['a.v1'])]) const seen: (readonly string[])[] = [] diff --git a/mobile/src/transport/runtime-capability-probe.ts b/mobile/src/transport/runtime-capability-probe.ts index ef636552863..b0f4ad1150d 100644 --- a/mobile/src/transport/runtime-capability-probe.ts +++ b/mobile/src/transport/runtime-capability-probe.ts @@ -27,17 +27,8 @@ export function startRuntimeCapabilityProbe( scheduleRetry(false) return } - const result = (response as RpcSuccess).result - const rawCapabilities = - result && typeof result === 'object' - ? (result as { capabilities?: unknown }).capabilities - : null - const capabilities = - Array.isArray(rawCapabilities) && - rawCapabilities.every((value) => typeof value === 'string') - ? rawCapabilities - : [] - onCapabilities(capabilities) + const status = (response as RpcSuccess).result as { capabilities?: string[] } + onCapabilities(status.capabilities ?? []) }, (error: unknown) => { if (cancelled) { diff --git a/mobile/src/transport/terminal-stream-protocol.ts b/mobile/src/transport/terminal-stream-protocol.ts index 384d66996f1..5113f38254a 100644 --- a/mobile/src/transport/terminal-stream-protocol.ts +++ b/mobile/src/transport/terminal-stream-protocol.ts @@ -1,3 +1,5 @@ +import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' + const TERMINAL_STREAM_KIND = 0x74 const TERMINAL_STREAM_VERSION = 1 const HEADER_BYTES = 16 @@ -52,13 +54,13 @@ export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFram opcode, streamId: view.getUint32(4, true), seq: high * 0x100000000 + low, - payload: bytes.slice(HEADER_BYTES) + payload: bytes.subarray(HEADER_BYTES) } } export function decodeTerminalStreamJson(payload: Uint8Array): T | null { try { - return JSON.parse(new TextDecoder().decode(payload)) as T + return parseMobileJsonTextWithinLimits(new TextDecoder().decode(payload)) } catch { return null } diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index d031d11ff08..ffd4afddd06 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -1,5 +1,10 @@ import { z } from 'zod' import { + PAIRING_CODE_MAX_CHARACTERS, + PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, + PAIRING_ENDPOINT_MAX_CHARACTERS, + PAIRING_INPUT_MAX_CHARACTERS, + PAIRING_PUBLIC_KEY_MAX_CHARACTERS, PairingOfferSchema, type PairingOffer } from '../../../src/shared/mobile-relay-pairing-offer' @@ -10,9 +15,20 @@ import { } from './mobile-relay-host-overlay' import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' -export { PairingOfferSchema } +export { + PAIRING_CODE_MAX_CHARACTERS, + PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, + PAIRING_ENDPOINT_MAX_CHARACTERS, + PAIRING_INPUT_MAX_CHARACTERS, + PAIRING_PUBLIC_KEY_MAX_CHARACTERS, + PairingOfferSchema +} export type { PairingOffer } +export const MOBILE_HOST_ID_MAX_CHARACTERS = 4_096 +export const MOBILE_HOST_NAME_MAX_CHARACTERS = 4_096 +export const MobileHostIdSchema = z.string().min(1).max(MOBILE_HOST_ID_MAX_CHARACTERS) + export type RpcRequest = { id: string deviceToken: string @@ -72,11 +88,11 @@ export type HostProfile = { } export const HostProfileSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - endpoint: z.string().min(1), - deviceToken: z.string().min(1), - publicKeyB64: z.string().min(1), + id: MobileHostIdSchema, + name: z.string().min(1).max(MOBILE_HOST_NAME_MAX_CHARACTERS), + endpoint: z.string().min(1).max(PAIRING_ENDPOINT_MAX_CHARACTERS), + deviceToken: z.string().min(1).max(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), + publicKeyB64: z.string().min(1).max(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), lastConnected: z.number().finite(), endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16).optional(), relayHostId: z @@ -90,10 +106,10 @@ export const HostProfileSchema = z.object({ // deviceToken is held in iOS Keychain via expo-secure-store and joined // in at load time; it must NOT appear in AsyncStorage anymore. export const StoredHostProfileSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - endpoint: z.string().min(1), - publicKeyB64: z.string().min(1), + id: MobileHostIdSchema, + name: z.string().min(1).max(MOBILE_HOST_NAME_MAX_CHARACTERS), + endpoint: z.string().min(1).max(PAIRING_ENDPOINT_MAX_CHARACTERS), + publicKeyB64: z.string().min(1).max(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), lastConnected: z.number().finite() }) diff --git a/mobile/src/transport/websocket-payload-bytes.test.ts b/mobile/src/transport/websocket-payload-bytes.test.ts index 4b501957ef5..dc630343b23 100644 --- a/mobile/src/transport/websocket-payload-bytes.test.ts +++ b/mobile/src/transport/websocket-payload-bytes.test.ts @@ -1,14 +1,64 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { websocketPayloadToUint8 } from './websocket-payload-bytes' describe('websocketPayloadToUint8', () => { it('returns null when a blob-like payload rejects arrayBuffer conversion', async () => { await expect( websocketPayloadToUint8({ + size: 0, arrayBuffer: async () => { throw new Error('conversion failed') } }) ).resolves.toBeNull() }) + + it('rejects a declared oversized payload before starting binary conversion', async () => { + const arrayBuffer = vi.fn(async () => new ArrayBuffer(1)) + + await expect(websocketPayloadToUint8({ size: 5, arrayBuffer }, 4)).rejects.toThrow( + 'exceeds inbound frame limit' + ) + expect(arrayBuffer).not.toHaveBeenCalled() + }) + + it('accepts an exact declared size and verifies the converted result', async () => { + const arrayBuffer = vi.fn(async () => new ArrayBuffer(4)) + + await expect(websocketPayloadToUint8({ size: 4, arrayBuffer }, 4)).resolves.toEqual( + new Uint8Array(4) + ) + expect(arrayBuffer).toHaveBeenCalledOnce() + }) + + it('rejects an unknown-size payload before starting binary conversion', async () => { + const arrayBuffer = vi.fn(async () => new ArrayBuffer(4)) + + await expect(websocketPayloadToUint8({ arrayBuffer }, 4)).rejects.toThrow( + 'exceeds inbound frame limit' + ) + expect(arrayBuffer).not.toHaveBeenCalled() + }) + + it.each([Number.NaN, -1, Number.POSITIVE_INFINITY])( + 'rejects invalid declared size %s before starting binary conversion', + async (size) => { + const arrayBuffer = vi.fn(async () => new ArrayBuffer(4)) + + await expect(websocketPayloadToUint8({ size, arrayBuffer }, 4)).rejects.toThrow( + 'exceeds inbound frame limit' + ) + expect(arrayBuffer).not.toHaveBeenCalled() + } + ) + + it('preserves direct Uint8Array and ArrayBuffer conversion', async () => { + const uint8 = new Uint8Array([1, 2, 3]) + const arrayBuffer = Uint8Array.from([4, 5, 6]).buffer + + await expect(websocketPayloadToUint8(uint8, 3)).resolves.toBe(uint8) + await expect(websocketPayloadToUint8(arrayBuffer, 3)).resolves.toEqual( + new Uint8Array([4, 5, 6]) + ) + }) }) diff --git a/mobile/src/transport/websocket-payload-bytes.ts b/mobile/src/transport/websocket-payload-bytes.ts index 1cd8ac5cdd8..af4b1685dd3 100644 --- a/mobile/src/transport/websocket-payload-bytes.ts +++ b/mobile/src/transport/websocket-payload-bytes.ts @@ -1,23 +1,48 @@ -export async function websocketPayloadToUint8(value: unknown): Promise { +import { + assertMobileInboundFrameSize, + MOBILE_INBOUND_MAX_FRAME_BYTES +} from './mobile-inbound-frame-queue' + +const OVERSIZED_BINARY_MESSAGE = 'WebSocket binary payload exceeds inbound frame limit' + +export async function websocketPayloadToUint8( + value: unknown, + maxBytes = MOBILE_INBOUND_MAX_FRAME_BYTES +): Promise { + assertMobileInboundFrameSize(value, OVERSIZED_BINARY_MESSAGE, maxBytes) if (value instanceof Uint8Array) { - return value + return enforceConvertedLimit(value, maxBytes) } if (value instanceof ArrayBuffer) { - return new Uint8Array(value) + return enforceConvertedLimit(new Uint8Array(value), maxBytes) } if (value && typeof value === 'object' && 'arrayBuffer' in value) { + const declaredBytes = declaredBinaryPayloadBytes(value) + if (declaredBytes === null || declaredBytes > maxBytes) { + throw new Error(OVERSIZED_BINARY_MESSAGE) + } const blob = value as { arrayBuffer: () => Promise } + let buffer: ArrayBuffer try { - return new Uint8Array(await blob.arrayBuffer()) + buffer = await blob.arrayBuffer() } catch { return null } + return enforceConvertedLimit(new Uint8Array(buffer), maxBytes) } if (typeof FileReader !== 'undefined' && value instanceof Blob) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { - resolve(reader.result instanceof ArrayBuffer ? new Uint8Array(reader.result) : null) + if (!(reader.result instanceof ArrayBuffer)) { + resolve(null) + return + } + try { + resolve(enforceConvertedLimit(new Uint8Array(reader.result), maxBytes)) + } catch (error) { + reject(error) + } } reader.onerror = () => resolve(null) reader.readAsArrayBuffer(value) @@ -25,3 +50,23 @@ export async function websocketPayloadToUint8(value: unknown): Promise)[key] + if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0) { + return candidate + } + } + return null +} + +function enforceConvertedLimit(bytes: Uint8Array, maxBytes: number): Uint8Array { + if (bytes.byteLength > maxBytes) { + throw new Error(OVERSIZED_BINARY_MESSAGE) + } + return bytes +} diff --git a/mobile/src/worktree/last-visited-worktree-repo.test.ts b/mobile/src/worktree/last-visited-worktree-repo.test.ts index 4d484fbe715..1860871603d 100644 --- a/mobile/src/worktree/last-visited-worktree-repo.test.ts +++ b/mobile/src/worktree/last-visited-worktree-repo.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from 'vitest' -import { readLastVisitedWorktreeRepoId } from './last-visited-worktree-repo' +import { describe, expect, it, vi } from 'vitest' +import { + LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS, + LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS, + readLastVisitedWorktreeRecord, + readLastVisitedWorktreeRepoId, + serializeLastVisitedWorktreeRecord +} from './last-visited-worktree-repo' describe('last visited worktree repo', () => { it('extracts the repo id for the current host', () => { @@ -18,4 +24,37 @@ describe('last visited worktree repo', () => { expect(readLastVisitedWorktreeRepoId('{', 'host-1')).toBeNull() expect(readLastVisitedWorktreeRepoId(JSON.stringify({ hostId: 'host-1' }), 'host-1')).toBeNull() }) + + it('round-trips exact field limits and rejects one character more', () => { + const exact = { + hostId: 'h'.repeat(LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS), + worktreeId: 'w'.repeat(LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS) + } + const serialized = serializeLastVisitedWorktreeRecord(exact) + + expect(serialized).not.toBeNull() + expect(readLastVisitedWorktreeRecord(serialized)).toEqual(exact) + expect( + serializeLastVisitedWorktreeRecord({ + ...exact, + worktreeId: `${exact.worktreeId}w` + }) + ).toBeNull() + }) + + it('accepts the exact raw limit and does not parse one character more', () => { + const record = { hostId: 'host-1', worktreeId: 'repo::worktree' } + const serialized = JSON.stringify(record) + const exact = + serialized + ' '.repeat(LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS - serialized.length) + + expect(readLastVisitedWorktreeRecord(exact)).toEqual(record) + + const parse = vi.spyOn(JSON, 'parse') + expect( + readLastVisitedWorktreeRecord('x'.repeat(LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS + 1)) + ).toBeNull() + expect(parse).not.toHaveBeenCalled() + parse.mockRestore() + }) }) diff --git a/mobile/src/worktree/last-visited-worktree-repo.ts b/mobile/src/worktree/last-visited-worktree-repo.ts index 4ac9349c9fc..57262429bed 100644 --- a/mobile/src/worktree/last-visited-worktree-repo.ts +++ b/mobile/src/worktree/last-visited-worktree-repo.ts @@ -1,8 +1,10 @@ import { getRepoIdFromMobileWorktreeId } from '../session/mobile-session-route-helpers' export const LAST_VISITED_WORKTREE_STORAGE_KEY = 'orca:last-visited-worktree' +export const LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS = 16 * 1024 +export const LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS = 4_096 -type LastVisitedWorktreeRecord = { +export type LastVisitedWorktreeRecord = { hostId: string worktreeId: string } @@ -11,8 +13,10 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -function readLastVisitedWorktreeRecord(raw: string | null): LastVisitedWorktreeRecord | null { - if (!raw) { +export function readLastVisitedWorktreeRecord( + raw: string | null +): LastVisitedWorktreeRecord | null { + if (!raw || raw.length > LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS) { return null } try { @@ -20,7 +24,11 @@ function readLastVisitedWorktreeRecord(raw: string | null): LastVisitedWorktreeR if ( !isRecord(parsed) || typeof parsed.hostId !== 'string' || - typeof parsed.worktreeId !== 'string' + parsed.hostId.length === 0 || + parsed.hostId.length > LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS || + typeof parsed.worktreeId !== 'string' || + parsed.worktreeId.length === 0 || + parsed.worktreeId.length > LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS ) { return null } @@ -30,6 +38,25 @@ function readLastVisitedWorktreeRecord(raw: string | null): LastVisitedWorktreeR } } +export function serializeLastVisitedWorktreeRecord( + record: LastVisitedWorktreeRecord +): string | null { + if ( + record.hostId.length === 0 || + record.hostId.length > LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS || + record.worktreeId.length === 0 || + record.worktreeId.length > LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS + ) { + return null + } + // bounded-by: both ids are length-checked above, and the result is rejected past the storage cap. + const serialized = JSON.stringify(record) + if (serialized.length > LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS) { + return null + } + return serialized +} + export function readLastVisitedWorktreeRepoId(raw: string | null, hostId: string): string | null { const record = readLastVisitedWorktreeRecord(raw) if (!record || record.hostId !== hostId) { diff --git a/src/cli/computer-screenshot-storage.test.ts b/src/cli/computer-screenshot-storage.test.ts new file mode 100644 index 00000000000..0ee2aa968e1 --- /dev/null +++ b/src/cli/computer-screenshot-storage.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { writeComputerScreenshotTempFile } from './computer-screenshot-storage' + +const roots: string[] = [] +const originalTempDir = process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR + +afterEach(() => { + if (originalTempDir === undefined) { + delete process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR + } else { + process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR = originalTempDir + } + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('computer screenshot storage', () => { + it('writes normal screenshot bytes and streams cleanup of expired captures', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-computer-screenshot-')) + roots.push(root) + process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR = root + const expiredPath = join(root, 'expired-screenshot.png') + const unrelatedPath = join(root, 'keep.txt') + writeFileSync(expiredPath, 'old') + writeFileSync(unrelatedPath, 'keep') + const expiredAt = new Date(Date.now() - 25 * 60 * 60 * 1000) + utimesSync(expiredPath, expiredAt, expiredAt) + + const result = writeComputerScreenshotTempFile( + 'rpc/id', + Buffer.from('png').toString('base64'), + 'png' + ) + + expect(result.outputPath).toBe(join(root, 'rpc_id-screenshot.png')) + expect(existsSync(result.outputPath)).toBe(true) + expect(existsSync(expiredPath)).toBe(false) + expect(existsSync(unrelatedPath)).toBe(true) + }) +}) diff --git a/src/cli/computer-screenshot-storage.ts b/src/cli/computer-screenshot-storage.ts new file mode 100644 index 00000000000..8c47a89d189 --- /dev/null +++ b/src/cli/computer-screenshot-storage.ts @@ -0,0 +1,96 @@ +import { + chmodSync, + lstatSync, + mkdirSync, + opendirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const COMPUTER_SCREENSHOT_TTL_MS = 24 * 60 * 60 * 1000 +const COMPUTER_SCREENSHOT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000 +const COMPUTER_SCREENSHOT_CLEANUP_MARKER = '.last-cleanup' + +export function writeComputerScreenshotTempFile( + responseId: string, + base64Data: string, + extension: 'png' | 'img' +): { outputPath: string; expiresAt: string } { + const outputDir = computerScreenshotTempDir() + cleanupComputerScreenshots(outputDir) + const outputPath = join(outputDir, `${safeCliFileStem(responseId)}-screenshot.${extension}`) + writeFileSync(outputPath, Buffer.from(base64Data, 'base64'), { mode: 0o600 }) + return { + outputPath, + expiresAt: new Date(Date.now() + COMPUTER_SCREENSHOT_TTL_MS).toISOString() + } +} + +function computerScreenshotTempDir(): string { + const outputDir = + process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR || join(tmpdir(), 'orca-computer-use') + mkdirSync(outputDir, { recursive: true, mode: 0o700 }) + const stat = lstatSync(outputDir) + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`Unsafe computer screenshot temp path: ${outputDir}`) + } + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + throw new Error(`Computer screenshot temp path is not owned by the current user: ${outputDir}`) + } + chmodSync(outputDir, 0o700) + return outputDir +} + +function cleanupComputerScreenshots(outputDir: string): void { + const now = Date.now() + const markerPath = join(outputDir, COMPUTER_SCREENSHOT_CLEANUP_MARKER) + try { + // Why: agents can call computer-use CLI commands in loops; a marker keeps + // temp cleanup from becoming a synchronous directory scan per screenshot. + if (statSync(markerPath).mtimeMs > now - COMPUTER_SCREENSHOT_CLEANUP_INTERVAL_MS) { + return + } + } catch { + // Missing or unreadable marker means this process should attempt cleanup. + } + + const cutoff = now - COMPUTER_SCREENSHOT_TTL_MS + const directory = opendirSync(outputDir) + try { + while (true) { + const entry = directory.readSync() + if (!entry) { + break + } + if (!entry.name.endsWith('-screenshot.png') && !entry.name.endsWith('-screenshot.img')) { + continue + } + const path = join(outputDir, entry.name) + try { + if (statSync(path).mtimeMs < cutoff) { + rmSync(path, { force: true }) + } + } catch { + // Best-effort cleanup only; formatting should not fail because a temp file raced. + } + } + } finally { + try { + directory.closeSync() + } catch { + // Best-effort cleanup only. + } + } + try { + writeFileSync(markerPath, `${now}\n`, { mode: 0o600 }) + } catch { + // Best-effort marker only; stale cleanup state should not hide a screenshot. + } +} + +function safeCliFileStem(value: string): string { + return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_') +} diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 4ae4c962d59..d1b92d44d8a 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -1,9 +1,10 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultPersistedState } from '../../shared/constants' import type { PersistedState } from '../../shared/types' +import { ORCA_PERSISTED_STATE_MAX_BYTES } from '../../shared/persisted-state-file-bounds' const { applyAgentStatusHooksEnabledMock, @@ -45,9 +46,12 @@ vi.mock('../runtime-client', () => { } } + class RuntimeRpcFailureError extends Error {} + return { RuntimeClient, RuntimeClientError, + RuntimeRpcFailureError, getDefaultUserDataPath: getDefaultUserDataPathMock } }) @@ -120,4 +124,16 @@ describe('agent hooks CLI handler', () => { expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) }) + + it('rejects an oversized sparse offline state file without replacing it', async () => { + const dataPath = join(userDataPath, 'orca-data.json') + writeFileSync(dataPath, '') + truncateSync(dataPath, ORCA_PERSISTED_STATE_MAX_BYTES + 1) + + await runAgentHooksOff(userDataPath) + + expect(process.exitCode).toBe(1) + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('File too large')) + expect(readFileSync(dataPath).byteLength).toBe(ORCA_PERSISTED_STATE_MAX_BYTES + 1) + }) }) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 2b83f5c15ea..8ef4d138b8e 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' @@ -13,6 +13,11 @@ import { getManagedAgentHookStatuses } from '../../main/agent-hooks/managed-agent-hook-controls' import { getDefaultUserDataPath } from '../runtime-client' +import { + ORCA_PERSISTED_STATE_MAX_BYTES, + readPersistedStateJsonFileSync, + stringifyPrettyPersistedStateWithinLimit +} from '../../shared/persisted-state-file-bounds' type AgentHookCommandResult = { enabled: boolean @@ -34,7 +39,7 @@ function readPersistedState(dataPath: string): PersistedState { return getDefaultPersistedState(homedir()) } try { - const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) + const { value: parsed } = readPersistedStateJsonFileSync(dataPath) if (!isRecord(parsed)) { throw new Error('file does not contain a JSON object') } @@ -52,7 +57,11 @@ function writePersistedState(dataPath: string, state: PersistedState): void { const tmpPath = join(dirname(dataPath), `.${Date.now()}-${randomUUID()}.tmp`) let renamed = false try { - writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8') + const { serialized } = stringifyPrettyPersistedStateWithinLimit( + state, + ORCA_PERSISTED_STATE_MAX_BYTES - 1 + ) + writeFileSync(tmpPath, `${serialized}\n`, 'utf-8') renameSync(tmpPath, dataPath) renamed = true } finally { diff --git a/src/cli/handlers/computer-action-flags.ts b/src/cli/handlers/computer-action-flags.ts index 384c1dc0d80..8c771ec8f62 100644 --- a/src/cli/handlers/computer-action-flags.ts +++ b/src/cli/handlers/computer-action-flags.ts @@ -2,6 +2,11 @@ import { computerUseHotkeyValidationMessage, computerUsePressKeyValidationMessage } from '../../shared/computer-use-key-spec' +import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../shared/clipboard-text' +import { + NodeReadableTextTooLargeError, + readNodeReadableTextWithinLimit +} from '../../shared/node-readable-text' import { getOptionalNonNegativeIntegerFlag, getOptionalNumberFlag, @@ -189,11 +194,17 @@ async function readStdin(): Promise { if (process.stdin.isTTY) { throw new RuntimeClientError('invalid_argument', 'stdin payload requested but stdin is a TTY') } - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + try { + return await readNodeReadableTextWithinLimit(process.stdin, CLIPBOARD_TEXT_WRITE_MAX_BYTES) + } catch (error) { + if (error instanceof NodeReadableTextTooLargeError) { + throw new RuntimeClientError( + 'invalid_argument', + `stdin payload must be at most ${CLIPBOARD_TEXT_WRITE_MAX_BYTES} bytes` + ) + } + throw error } - return Buffer.concat(chunks).toString('utf8') } function getOptionalPositiveNumberFlag( diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index cdafcd66743..361add206d0 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -79,6 +79,25 @@ type OrchestrationSendResult = | { message: { id: string }; lifecycle?: LifecycleSendRejection } | { messages: { id: string }[]; recipients: number } +type OrchestrationRetentionPage = { + count: number + truncated?: boolean + total?: number + remaining?: number +} + +function appendOrchestrationRetentionNotice( + body: string, + page: OrchestrationRetentionPage, + noun: string +): string { + if (!page.truncated) { + return body + } + const omitted = page.remaining ?? Math.max(0, (page.total ?? page.count) - page.count) + return `${body}\n\nTruncated: ${omitted} additional ${noun} omitted.` +} + function getOptionalStructuredMessagePayload( flags: Map ): string | undefined { @@ -399,6 +418,8 @@ export const ORCHESTRATION_HANDLERS: Record = { messages: MessageSummary[] count: number formatted?: string + truncated?: boolean + remaining?: number } let result: Awaited>> try { @@ -445,15 +466,17 @@ export const ORCHESTRATION_HANDLERS: Record = { } } printResult(result, json, (r) => { + let body: string if (r.formatted) { - return r.formatted + body = r.formatted + } else if (r.count === 0) { + body = 'No messages.' + } else { + body = r.messages + .map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`) + .join('\n') } - if (r.count === 0) { - return 'No messages.' - } - return r.messages - .map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`) - .join('\n') + return appendOrchestrationRetentionNotice(body, r, 'messages') }) }, @@ -472,31 +495,34 @@ export const ORCHESTRATION_HANDLERS: Record = { const result = await client.call<{ messages: MessageSummary[] count: number + total?: number + truncated?: boolean }>('orchestration.inbox', { limit: getOptionalPositiveIntegerFlag(flags, 'limit'), terminal: getOptionalStringFlag(flags, 'terminal') }) printResult(result, json, (r) => { - if (r.count === 0) { - return 'No messages.' - } // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. - return r.messages - .map((m) => { - const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` - if (!full) { - return head - } - const parts = [head] - if (m.body && m.body.length > 0) { - parts.push(m.body) - } - if (m.payload) { - parts.push(`[payload] ${m.payload}`) - } - return parts.join('\n') - }) - .join(full ? '\n\n' : '\n') + const body = + r.count === 0 + ? 'No messages.' + : r.messages + .map((m) => { + const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` + if (!full) { + return head + } + const parts = [head] + if (m.body && m.body.length > 0) { + parts.push(m.body) + } + if (m.payload) { + parts.push(`[payload] ${m.payload}`) + } + return parts.join('\n') + }) + .join(full ? '\n\n' : '\n') + return appendOrchestrationRetentionNotice(body, r, 'messages') }) }, @@ -530,6 +556,8 @@ export const ORCHESTRATION_HANDLERS: Record = { spec_truncated?: boolean }[] count: number + total?: number + truncated?: boolean }>('orchestration.taskList', { status: getOptionalStringFlag(flags, 'status'), ready: flags.has('ready') ? true : undefined, @@ -545,19 +573,20 @@ export const ORCHESTRATION_HANDLERS: Record = { } : result printResult(output, json, (r) => { - if (r.count === 0) { - return 'No tasks.' - } - return r.tasks - .map((t) => { - const label = t.display_name ?? t.task_title ?? t.spec - const head = `${t.id} [${t.status}] ${label.slice(0, 60)}` - if (t.status === 'dispatched' && t.assignee_handle) { - return `${head} -> ${t.assignee_handle} (${t.dispatch_id ?? '?'})` - } - return head - }) - .join('\n') + const body = + r.count === 0 + ? 'No tasks.' + : r.tasks + .map((t) => { + const label = t.display_name ?? t.task_title ?? t.spec + const head = `${t.id} [${t.status}] ${label.slice(0, 60)}` + if (t.status === 'dispatched' && t.assignee_handle) { + return `${head} -> ${t.assignee_handle} (${t.dispatch_id ?? '?'})` + } + return head + }) + .join('\n') + return appendOrchestrationRetentionNotice(body, r, 'tasks') }) }, @@ -618,6 +647,7 @@ export const ORCHESTRATION_HANDLERS: Record = { messageId: string | null threadId: string timedOut: boolean + timeoutMs?: number }>( 'orchestration.ask', { @@ -638,7 +668,9 @@ export const ORCHESTRATION_HANDLERS: Record = { } if (result.result.timedOut) { if (!json) { - console.error(`ask timeout after ${timeoutMs}ms (thread ${result.result.threadId})`) + // Why: report the server's effective budget — it clamps large values, so the requested one would overstate the wait. + const waitedMs = result.result.timeoutMs ?? timeoutMs + console.error(`ask timeout after ${waitedMs}ms (thread ${result.result.threadId})`) } process.exitCode = 1 } @@ -722,17 +754,18 @@ export const ORCHESTRATION_HANDLERS: Record = { const result = await client.call<{ gates: { id: string; task_id: string; question: string; status: string }[] count: number + total?: number + truncated?: boolean }>('orchestration.gateList', { task: getOptionalStringFlag(flags, 'task'), status: getOptionalStringFlag(flags, 'status') }) printResult(result, json, (r) => { - if (r.gates.length === 0) { - return 'No gates found.' - } - return r.gates - .map((g) => `${g.id} task=${g.task_id} [${g.status}] "${g.question}"`) - .join('\n') + const body = + r.gates.length === 0 + ? 'No gates found.' + : r.gates.map((g) => `${g.id} task=${g.task_id} [${g.status}] "${g.question}"`).join('\n') + return appendOrchestrationRetentionNotice(body, r, 'gates') }) }, diff --git a/src/cli/handlers/vm.ts b/src/cli/handlers/vm.ts index aaf01b08872..246d872d7a3 100644 --- a/src/cli/handlers/vm.ts +++ b/src/cli/handlers/vm.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { join } from 'node:path' import type { CommandHandler } from '../dispatch' import { RuntimeClientError } from '../runtime-client' @@ -18,6 +18,8 @@ import { runEphemeralVmRecipeStart } from '../../shared/ephemeral-vm-recipe-runner' import type { OrcaVmRecipe } from '../../shared/types' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { MAX_ORCA_YAML_BYTES } from '../../shared/orca-yaml-file-limit' export const VM_HANDLERS: Record = { 'vm recipe doctor': async ({ flags, cwd, json }) => { @@ -80,7 +82,7 @@ function doctorRecipe(repoPath: string, recipeId: string): DoctorResult { } function readTextFile(path: string): string { - return readFileSync(path, 'utf8') + return readNodeFileSyncWithinLimit(path, MAX_ORCA_YAML_BYTES).buffer.toString('utf8') } // Why: give the agent the full create/destroy output so it can self-diagnose a diff --git a/src/cli/linear-request-builders.ts b/src/cli/linear-request-builders.ts index 34d1ee9d473..0ee312afbe5 100644 --- a/src/cli/linear-request-builders.ts +++ b/src/cli/linear-request-builders.ts @@ -1,4 +1,3 @@ -import { readFile } from 'node:fs/promises' import { isAbsolute, join } from 'node:path' import type { LinearIssueInclude, @@ -21,6 +20,16 @@ import { getRequiredStringFlagAllowingEmpty } from './flags' import { RuntimeClientError } from './runtime-client' +import { + NodeReadableTextTooLargeError, + readNodeReadableTextWithinLimit +} from '../shared/node-readable-text' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../shared/memory-safety/node-bounded-file-reader' + +const LINEAR_WRITE_BODY_MAX_BYTES = LINEAR_WRITE_BODY_CAP * 4 const LINEAR_PRIORITY_VALUES = new Map([ ['none', 0], @@ -262,14 +271,35 @@ export async function readLinearBody( async function readLinearBodyFile(path: string, cwd: string): Promise { if (path !== '-') { - return await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8') + try { + const { buffer } = await readNodeFileWithinLimit( + isAbsolute(path) ? path : join(cwd, path), + LINEAR_WRITE_BODY_MAX_BYTES + ) + return buffer.toString('utf8') + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw linearBodyTooLargeError() + } + throw error + } } if (process.stdin.isTTY) { throw new RuntimeClientError('invalid_argument', 'stdin body requested but stdin is a TTY') } - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + try { + return await readNodeReadableTextWithinLimit(process.stdin, LINEAR_WRITE_BODY_MAX_BYTES) + } catch (error) { + if (error instanceof NodeReadableTextTooLargeError) { + throw linearBodyTooLargeError() + } + throw error } - return Buffer.concat(chunks).toString('utf8') +} + +function linearBodyTooLargeError(): RuntimeClientError { + return new RuntimeClientError( + 'linear_body_too_large', + `Linear body must be at most ${LINEAR_WRITE_BODY_CAP} characters` + ) } diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index 235b2b2ed56..cc8c33c7cf8 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -519,6 +519,33 @@ describe('serveOrcaApp', () => { expect(child.unref).toHaveBeenCalledOnce() }) + it('preserves recipe JSON after 100,000 one-byte fragments', async () => { + const { child, result, stdoutSpy } = startRecipeJsonServer() + const output = Buffer.from(`${' '.repeat(100_000)}${RECIPE_JSON}\n`) + queueMicrotask(() => { + for (let index = 0; index < output.byteLength; index += 1) { + child.stdout.emit('data', output.subarray(index, index + 1)) + } + }) + + await expect(result).resolves.toBe(0) + expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`) + }) + + it('kills a server that emits an oversized recipe line without a newline', async () => { + const { child, result } = startRecipeJsonServer() + queueMicrotask(() => { + child.stdout.emit('data', Buffer.alloc(4 * 1024 * 1024 + 1, 0x78)) + }) + + await expect(result).rejects.toMatchObject({ + code: 'runtime_serve_failed', + message: 'Recipe JSON output exceeded 4194304 byte line limit.' + }) + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + expect(child.stdout.listenerCount('data')).toBe(0) + }) + it('rejects when the server exits without valid recipe JSON', async () => { const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer() const secrets = ['UPPER-SECRET', 'SLASH-SECRET', 'LEGACY-SECRET', 'PRIVATE-SECRET'] diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index bd7d939be5a..9bdd9f04252 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,16 +1,12 @@ import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process' import { resolve } from 'node:path' -import { StringDecoder } from 'node:string_decoder' import { SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath } from '../../shared/serve-update-handoff' -import { - getEphemeralVmRecipeResultConnection, - parseEphemeralVmRecipeResult -} from '../../shared/ephemeral-vm-recipes' import { getDefaultUserDataPath } from './metadata' import { getMacAppBundlePath } from './mac-app-update-bundle' +import { waitForRecipeJson } from './recipe-json-output' import { readServeUpdateHandoffSync, resumeInterruptedServeUpdate, @@ -18,8 +14,6 @@ import { } from './serve-update-supervisor' import { RuntimeClientError } from './types' -const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' - export function launchOrcaApp(): void { const overrideCommand = process.env.ORCA_OPEN_COMMAND if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) { @@ -165,97 +159,6 @@ export function serveOrcaApp( }) } -function waitForRecipeJson(child: ReturnType): Promise { - return new Promise((resolve, reject) => { - let output = '' - let settled = false - const timeout = setTimeout(() => { - finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.')) - child.kill('SIGTERM') - }, 60000) - const finish = (error?: Error): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - child.stdout?.off('data', onData) - child.off('error', onError) - child.off('close', onClose) - if (error) { - reject(error) - return - } - child.stdout?.destroy?.() - child.unref() - resolve(0) - } - const writeIgnoredRecipeStdout = (): void => { - // Why: non-readiness child stdout is untrusted and cannot be safely - // redacted, including schema-valid results with arbitrary user data. - process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`) - } - const processRecipeOutputLine = (line: string): void => { - const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line - if (!normalizedLine.trim()) { - return - } - const parsed = parseEphemeralVmRecipeResult(normalizedLine) - if (!parsed.ok) { - writeIgnoredRecipeStdout() - return - } - if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') { - writeIgnoredRecipeStdout() - return - } - process.stdout.write(`${normalizedLine.trim()}\n`) - finish() - } - const stdoutDecoder = new StringDecoder('utf8') - const onData = (chunk: Buffer | string): void => { - output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk) - while (!settled) { - const newlineIndex = output.indexOf('\n') - if (newlineIndex === -1) { - return - } - const line = output.slice(0, newlineIndex) - output = output.slice(newlineIndex + 1) - processRecipeOutputLine(line) - } - } - const onError = (error: Error): void => { - finish(error) - } - const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { - if (settled) { - return - } - output += stdoutDecoder.end() - if (output.trim()) { - processRecipeOutputLine(output) - } - if (settled) { - return - } - finish( - new RuntimeClientError( - 'runtime_serve_failed', - typeof code === 'number' - ? `Orca serve exited before printing valid recipe JSON with code ${code}.` - : `Orca serve exited before printing valid recipe JSON via ${signal}.` - ) - ) - } - child.stdout?.on('data', onData) - child.once('error', onError) - // Why: `exit` can precede the final piped stdout data. `close` waits until - // stdio closes so a last recipe chunk is not mistaken for missing output. - child.once('close', onClose) - }) -} - function getExecutableAppArgs(): string[] { return process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : [] } diff --git a/src/cli/runtime/recipe-json-output.ts b/src/cli/runtime/recipe-json-output.ts new file mode 100644 index 00000000000..bcbe5e3fc2e --- /dev/null +++ b/src/cli/runtime/recipe-json-output.ts @@ -0,0 +1,120 @@ +import type { ChildProcess } from 'node:child_process' +import { + getEphemeralVmRecipeResultConnection, + parseEphemeralVmRecipeResult +} from '../../shared/ephemeral-vm-recipes' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' +import { RuntimeClientError } from './types' + +const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' +const MAX_RECIPE_OUTPUT_LINE_BYTES = 4 * 1024 * 1024 + +export function waitForRecipeJson(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const output = new GrowingByteBuffer() + let settled = false + const timeout = setTimeout(() => { + finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.')) + child.kill('SIGTERM') + }, 60000) + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + output.clear() + child.stdout?.off('data', onData) + child.off('error', onError) + child.off('close', onClose) + if (error) { + reject(error) + return + } + child.stdout?.destroy?.() + child.unref() + resolve(0) + } + const writeIgnoredRecipeStdout = (): void => { + // Why: non-readiness child stdout can contain arbitrary user data. + process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`) + } + const processRecipeOutputLine = (line: string): void => { + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line + if (!normalizedLine.trim()) { + return + } + const parsed = parseEphemeralVmRecipeResult(normalizedLine) + if (!parsed.ok) { + writeIgnoredRecipeStdout() + return + } + if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') { + writeIgnoredRecipeStdout() + return + } + process.stdout.write(`${normalizedLine.trim()}\n`) + finish() + } + const oversizedLineError = (): RuntimeClientError => + new RuntimeClientError( + 'runtime_serve_failed', + `Recipe JSON output exceeded ${MAX_RECIPE_OUTPUT_LINE_BYTES} byte line limit.` + ) + const onData = (chunk: Buffer | string): void => { + if (settled) { + return + } + output.append(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + while (!settled) { + const newlineIndex = output.indexOfByte(0x0a) + if (newlineIndex === -1) { + break + } + if (newlineIndex > MAX_RECIPE_OUTPUT_LINE_BYTES) { + finish(oversizedLineError()) + child.kill('SIGTERM') + return + } + const line = output.takePrefixString(newlineIndex) + output.discardPrefix(1) + processRecipeOutputLine(line) + } + if (!settled && output.byteLength > MAX_RECIPE_OUTPUT_LINE_BYTES) { + finish(oversizedLineError()) + child.kill('SIGTERM') + } + } + const onError = (error: Error): void => { + finish(error) + } + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if (settled) { + return + } + if (output.byteLength > MAX_RECIPE_OUTPUT_LINE_BYTES) { + finish(oversizedLineError()) + return + } + const finalOutput = output.takeString() + if (finalOutput.trim()) { + processRecipeOutputLine(finalOutput) + } + if (settled) { + return + } + finish( + new RuntimeClientError( + 'runtime_serve_failed', + typeof code === 'number' + ? `Orca serve exited before printing valid recipe JSON with code ${code}.` + : `Orca serve exited before printing valid recipe JSON via ${signal}.` + ) + ) + } + child.stdout?.on('data', onData) + child.once('error', onError) + // Why: `close` waits for final piped stdout data that can arrive after `exit`. + child.once('close', onClose) + }) +} diff --git a/src/cli/runtime/transport.test.ts b/src/cli/runtime/transport.test.ts index d722de22065..cf084f5d80a 100644 --- a/src/cli/runtime/transport.test.ts +++ b/src/cli/runtime/transport.test.ts @@ -2,9 +2,9 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Socket } from 'node:net' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' -import { sendRequest } from './transport' +import { CLI_RUNTIME_JSON_STRUCTURE_LIMITS, sendRequest } from './transport' const servers = new Set>() const sockets = new Set() @@ -114,4 +114,36 @@ describe.skipIf(process.platform === 'win32')('runtime transport', () => { }) expect(Date.now() - start).toBeLessThan(5000) }) + + it('rejects excessive response nesting before JSON.parse', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', () => { + const depth = CLI_RUNTIME_JSON_STRUCTURE_LIMITS.nestingDepth + 1 + socket.write(`${'['.repeat(depth)}0${']'.repeat(depth)}\n`) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + const parseSpy = vi.spyOn(JSON, 'parse') + const metadata: RuntimeMetadata = { + runtimeId: 'runtime-1', + pid: 123, + transports: [{ kind: 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + } + + try { + await expect(sendRequest(metadata, 'status.get', undefined, 1000)).rejects.toMatchObject({ + code: 'invalid_runtime_response' + }) + expect(parseSpy).not.toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } + }) }) diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 60da1e2a980..167c4c3ada9 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -1,9 +1,17 @@ import { createConnection } from 'node:net' import { randomUUID } from 'node:crypto' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' +import { assertJsonTextStructureWithinLimits } from '../../shared/memory-safety/json-text-structure-limit' import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema' import { RuntimeClientError, type RuntimeRpcResponse } from './types' +const MAX_RUNTIME_RESPONSE_LINE_BYTES = 64 * 1024 * 1024 +export const CLI_RUNTIME_JSON_STRUCTURE_LIMITS = { + structuralTokens: 1_000_000, + nestingDepth: 128 +} as const + export async function sendRequest( metadata: RuntimeMetadata, method: string, @@ -22,7 +30,7 @@ export async function sendRequest( return } const socket = createConnection(transport.endpoint) - let buffer = '' + const buffer = new GrowingByteBuffer() let settled = false const requestId = randomUUID() @@ -48,15 +56,16 @@ export async function sendRequest( } settled = true clearTimeout(timeout) - socket.end() + buffer.clear() if (result.ok === false) { + socket.destroy() reject(result.error) } else { + socket.end() resolve(result.response) } } - socket.setEncoding('utf8') socket.once('error', () => { finish({ ok: false, @@ -79,24 +88,39 @@ export async function sendRequest( ) }) }) - socket.on('data', (chunk) => { - buffer += chunk + socket.on('data', (chunk: Buffer) => { + if (settled) { + return + } + buffer.append(chunk) // Why: the server may interleave `{"_keepalive":true}\n` frames with the // final success/failure frame to keep both idle timers alive during a // long-poll (see design doc §3.1). Read frames in a loop until we see a // terminal frame. Each keepalive refreshes the client-side timer so a // 10 min wait doesn't trip the 60 s default ceiling. - let newlineIndex = buffer.indexOf('\n') + let newlineIndex = buffer.indexOfByte(0x0a) while (newlineIndex !== -1 && !settled) { - const line = buffer.slice(0, newlineIndex) - buffer = buffer.slice(newlineIndex + 1) + const lineBytes = newlineIndex + if (lineBytes > MAX_RUNTIME_RESPONSE_LINE_BYTES) { + finish({ + ok: false, + error: new RuntimeClientError( + 'invalid_runtime_response', + `The Orca runtime response exceeded the ${MAX_RUNTIME_RESPONSE_LINE_BYTES} byte frame limit.` + ) + }) + return + } + const line = buffer.takePrefixString(lineBytes) + buffer.discardPrefix(1) if (line.trim().length === 0) { - newlineIndex = buffer.indexOf('\n') + newlineIndex = buffer.indexOfByte(0x0a) continue } let raw: unknown try { + assertJsonTextStructureWithinLimits(line, CLI_RUNTIME_JSON_STRUCTURE_LIMITS) raw = JSON.parse(line) } catch { finish({ @@ -115,7 +139,7 @@ export async function sendRequest( // major). See §7 risk #9. if (isKeepaliveFrame(raw)) { timeout.refresh() - newlineIndex = buffer.indexOf('\n') + newlineIndex = buffer.indexOfByte(0x0a) continue } @@ -141,7 +165,7 @@ export async function sendRequest( const frame = parsed.data if ('_keepalive' in frame) { timeout.refresh() - newlineIndex = buffer.indexOf('\n') + newlineIndex = buffer.indexOfByte(0x0a) continue } @@ -169,6 +193,15 @@ export async function sendRequest( finish({ ok: true, response }) return } + if (buffer.byteLength > MAX_RUNTIME_RESPONSE_LINE_BYTES) { + finish({ + ok: false, + error: new RuntimeClientError( + 'invalid_runtime_response', + `The Orca runtime response exceeded the ${MAX_RUNTIME_RESPONSE_LINE_BYTES} byte frame limit.` + ) + }) + } }) socket.on('connect', () => { socket.write( diff --git a/src/main/agent-hooks/agent-hook-file-comparison.ts b/src/main/agent-hooks/agent-hook-file-comparison.ts new file mode 100644 index 00000000000..96980214e3b --- /dev/null +++ b/src/main/agent-hooks/agent-hook-file-comparison.ts @@ -0,0 +1,15 @@ +import { + NodeFileReadTooLargeError, + readNodeFileSyncWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' + +export function readAgentHookFileForComparison(path: string, maxBytes: number): string | null { + try { + return readNodeFileSyncWithinLimit(path, maxBytes).buffer.toString('utf8') + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw error + } + return null + } +} diff --git a/src/main/agent-hooks/agent-hook-file-limits.ts b/src/main/agent-hooks/agent-hook-file-limits.ts new file mode 100644 index 00000000000..f321f114c18 --- /dev/null +++ b/src/main/agent-hooks/agent-hook-file-limits.ts @@ -0,0 +1,6 @@ +// Why: leave room for the supported 30,000-hook cleanup, including longer encoded Windows commands. +export const AGENT_HOOK_CONFIG_MAX_BYTES = 64 * 1024 * 1024 +export const AGENT_HOOK_CONFIG_MAX_STRUCTURAL_TOKENS = 1_000_000 +export const AGENT_HOOK_CONFIG_MAX_NESTING_DEPTH = 128 +export const AGENT_HOOK_PLUGIN_MAX_BYTES = 1024 * 1024 +export const AGENT_HOOK_MANAGED_SCRIPT_MAX_BYTES = 1024 * 1024 diff --git a/src/main/agent-hooks/agent-hook-sftp-text-reader.ts b/src/main/agent-hooks/agent-hook-sftp-text-reader.ts new file mode 100644 index 00000000000..112e9ce5a44 --- /dev/null +++ b/src/main/agent-hooks/agent-hook-sftp-text-reader.ts @@ -0,0 +1,151 @@ +import type { SFTPWrapper } from 'ssh2' +import { NodeFileReadTooLargeError } from '../../shared/memory-safety/node-bounded-file-reader' + +const READ_CHUNK_BYTES = 64 * 1024 + +type SftpWithBoundedAgentHookRead = SFTPWrapper & { + orcaReadFileWithinLimit?: ( + remotePath: string, + maxBytes: number, + callback: (error: unknown, data?: string | Buffer) => void + ) => void +} + +export async function readAgentHookRemoteTextFile( + sftp: SFTPWrapper, + remotePath: string, + maxBytes: number, + timeoutMs: number +): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + throw new RangeError('Remote file read limit must be a non-negative safe integer') + } + const boundedSftp = sftp as SftpWithBoundedAgentHookRead + if (typeof boundedSftp.orcaReadFileWithinLimit === 'function') { + return readWithCallback(remotePath, maxBytes, timeoutMs, (callback) => + boundedSftp.orcaReadFileWithinLimit!(remotePath, maxBytes, callback) + ) + } + if (typeof sftp.createReadStream !== 'function') { + return readWithCompatibilityFallback(sftp, remotePath, maxBytes, timeoutMs) + } + + const stream = sftp.createReadStream(remotePath, { + start: 0, + end: maxBytes, + highWaterMark: READ_CHUNK_BYTES + }) + return new Promise((resolve, reject) => { + let settled = false + let totalBytes = 0 + let retained = Buffer.alloc(0) + const timer = setTimeout(() => { + fail(new Error(`Timed out waiting for SFTP readFile ${remotePath}`)) + }, timeoutMs) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + + function fail(error: unknown): void { + if (settled) { + return + } + settled = true + clearTimeout(timer) + stream.destroy() + reject(error) + } + + stream.on('data', (chunk: string | Buffer | Uint8Array) => { + if (settled) { + return + } + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > maxBytes) { + fail(new NodeFileReadTooLargeError(totalBytes, maxBytes)) + return + } + if (retained.length < totalBytes) { + const nextCapacity = Math.min( + maxBytes, + Math.max(READ_CHUNK_BYTES, retained.length * 2, totalBytes) + ) + const next = Buffer.allocUnsafe(nextCapacity) + retained.copy(next) + retained = next + } + bytes.copy(retained, totalBytes - bytes.byteLength) + }) + stream.once('error', fail) + stream.once('end', () => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + resolve(retained.toString('utf8', 0, totalBytes)) + }) + }) +} + +function readWithCompatibilityFallback( + sftp: SFTPWrapper, + remotePath: string, + maxBytes: number, + timeoutMs: number +): Promise { + return readWithCallback(remotePath, maxBytes, timeoutMs, (callback) => { + sftp.readFile(remotePath, 'utf8', callback) + }) +} + +function readWithCallback( + remotePath: string, + maxBytes: number, + timeoutMs: number, + start: (callback: (error: unknown, data?: string | Buffer) => void) => void +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + finish(new Error(`Timed out waiting for SFTP readFile ${remotePath}`)) + }, timeoutMs) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + + function finish(error: unknown, data?: string | Buffer): void { + if (settled) { + return + } + settled = true + clearTimeout(timer) + if (error) { + reject(error) + return + } + if (typeof data === 'string') { + const byteLength = Buffer.byteLength(data, 'utf8') + if (byteLength > maxBytes) { + reject(new NodeFileReadTooLargeError(byteLength, maxBytes)) + return + } + resolve(data) + return + } + const buffer = data ?? Buffer.alloc(0) + if (buffer.byteLength > maxBytes) { + reject(new NodeFileReadTooLargeError(buffer.byteLength, maxBytes)) + return + } + resolve(buffer.toString('utf8')) + } + + try { + start(finish) + } catch (error) { + finish(error) + } + }) +} diff --git a/src/main/agent-hooks/hooks-json-read.ts b/src/main/agent-hooks/hooks-json-read.ts index c8536fad0b4..ead11692c0c 100644 --- a/src/main/agent-hooks/hooks-json-read.ts +++ b/src/main/agent-hooks/hooks-json-read.ts @@ -1,5 +1,12 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { assertJsonTextStructureWithinLimits } from '../../shared/memory-safety/json-text-structure-limit' import type { HooksConfig } from './installer-utils' +import { + AGENT_HOOK_CONFIG_MAX_BYTES, + AGENT_HOOK_CONFIG_MAX_NESTING_DEPTH, + AGENT_HOOK_CONFIG_MAX_STRUCTURAL_TOKENS +} from './agent-hook-file-limits' export function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -11,6 +18,19 @@ export type HooksJsonSnapshot = { config: HooksConfig | null } +export function parseHooksJsonText(raw: string): HooksConfig | null { + try { + assertJsonTextStructureWithinLimits(raw, { + structuralTokens: AGENT_HOOK_CONFIG_MAX_STRUCTURAL_TOKENS, + nestingDepth: AGENT_HOOK_CONFIG_MAX_NESTING_DEPTH + }) + const parsed = JSON.parse(raw) + return isPlainObject(parsed) ? parsed : null + } catch { + return null + } +} + // Why: generation guards abort a mutation when the file no longer matches the // bytes it was derived from; the raw snapshot and the parse must come from one // read or a concurrent save can slip between them unnoticed. @@ -20,18 +40,21 @@ export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot { } let raw: string try { - raw = readFileSync(configPath, 'utf-8') + raw = readNodeFileSyncWithinLimit(configPath, AGENT_HOOK_CONFIG_MAX_BYTES).buffer.toString( + 'utf8' + ) } catch { return { raw: null, config: null } } - try { - const parsed = JSON.parse(raw) - return { raw, config: isPlainObject(parsed) ? parsed : null } - } catch { - return { raw, config: null } - } + return { raw, config: parseHooksJsonText(raw) } } export function readHooksJson(configPath: string): HooksConfig | null { return readHooksJsonWithRaw(configPath).config } + +export function readHooksJsonRawForGenerationCheck(configPath: string): string { + return readNodeFileSyncWithinLimit(configPath, AGENT_HOOK_CONFIG_MAX_BYTES).buffer.toString( + 'utf8' + ) +} diff --git a/src/main/agent-hooks/installer-utils-remote.test.ts b/src/main/agent-hooks/installer-utils-remote.test.ts index 75f9c9682fe..f719324cd7a 100644 --- a/src/main/agent-hooks/installer-utils-remote.test.ts +++ b/src/main/agent-hooks/installer-utils-remote.test.ts @@ -1,8 +1,11 @@ +import { Readable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { SFTPWrapper } from 'ssh2' +import { NodeFileReadTooLargeError } from '../../shared/memory-safety/node-bounded-file-reader' import { readHooksJsonRemote, + readTextFileRemote, writeHooksJsonRemote, writeManagedScriptRemote, writeTextFileRemoteAtomic @@ -13,6 +16,7 @@ type FakeFs = { dirs: Set modes: Map openSshRenameCount: number + readdirCount: number } function createFakeSftp( @@ -30,7 +34,8 @@ function createFakeSftp( files: new Map(), dirs: new Set(['/']), modes: new Map(), - openSshRenameCount: 0 + openSshRenameCount: 0, + readdirCount: 0 } const noEntryError = (path: string): { code: number; message: string } => ({ code: 2, @@ -96,6 +101,10 @@ function createFakeSftp( cb(null) }, stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => { + if (fs.dirs.has(path)) { + cb(null, fakeStats(0o040755)) + return + } if (!fs.files.has(path)) { cb(noEntryError(path)) return @@ -103,6 +112,7 @@ function createFakeSftp( cb(null, fakeStats(fs.modes.get(path) ?? 0o100644)) }, readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { + fs.readdirCount += 1 if (fs.dirs.has(path)) { cb(null, []) return @@ -201,6 +211,14 @@ describe('installer-utils-remote', () => { expect(fs.modes.get('/home/u/.claude/settings.json')).toBe(0o600) }) + it('probes mkdir ancestors with stat instead of materializing directory listings', async () => { + const { sftp, fs } = createFakeSftp() + + await writeHooksJsonRemote(sftp, '/home/u/.claude/settings.json', { hooks: {} }) + + expect(fs.readdirCount).toBe(0) + }) + it('preserves existing config file mode across atomic replacement', async () => { const { sftp, fs } = createFakeSftp() const path = '/home/u/.codex/config.toml' @@ -287,4 +305,44 @@ describe('installer-utils-remote', () => { await writeHooksJsonRemote(sftp, path, { hooks: {} }) expect(fs.files.get(path)).toBe(beforeKey) }) + + it('preserves ordinary UTF-8 contents through the bounded remote stream', async () => { + const { sftp } = createFakeSftp() + const content = 'stable 🐋 config' + sftp.createReadStream = (() => Readable.from([Buffer.from(content)])) as never + + await expect(readTextFileRemote(sftp, '/home/u/config.toml', 1024)).resolves.toBe(content) + }) + + it('accepts a streamed remote file at the exact byte cap', async () => { + const { sftp } = createFakeSftp() + sftp.createReadStream = (() => Readable.from([Buffer.alloc(1024, 0x61)])) as never + + await expect(readTextFileRemote(sftp, '/home/u/config.toml', 1024)).resolves.toHaveLength(1024) + }) + + it('bounds metadata when a remote emits one byte per stream event', async () => { + const { sftp } = createFakeSftp() + const byte = Buffer.from('x') + sftp.createReadStream = (() => + Readable.from(Array.from({ length: 100_000 }, () => byte))) as never + + await expect(readTextFileRemote(sftp, '/home/u/config.toml', 100_000)).resolves.toBe( + 'x'.repeat(100_000) + ) + }) + + it('stops streamed remote reads at the byte cap and leaves the existing file intact', async () => { + const { sftp, fs } = createFakeSftp() + const path = '/home/u/.config/agent/config.toml' + fs.files.set(path, 'original') + sftp.createReadStream = (() => Readable.from([Buffer.alloc(1025)])) as never + + await expect(readTextFileRemote(sftp, path, 1024)).rejects.toThrow(NodeFileReadTooLargeError) + await expect(writeTextFileRemoteAtomic(sftp, path, 'replacement', 1024)).rejects.toThrow( + NodeFileReadTooLargeError + ) + expect(fs.files.get(path)).toBe('original') + expect(Array.from(fs.files.keys()).some((key) => key.includes('.tmp'))).toBe(false) + }) }) diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index c8edd5e8060..8928d27b7b2 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -12,9 +12,16 @@ // See docs/design/agent-status-over-ssh.md §8 (commit #8). import { randomUUID } from 'node:crypto' -import type { SFTPWrapper, FileEntryWithStats } from 'ssh2' +import type { SFTPWrapper } from 'ssh2' -import { isPlainObject, type HooksConfig } from './installer-utils' +import { NodeFileReadTooLargeError } from '../../shared/memory-safety/node-bounded-file-reader' +import { + AGENT_HOOK_CONFIG_MAX_BYTES, + AGENT_HOOK_MANAGED_SCRIPT_MAX_BYTES +} from './agent-hook-file-limits' +import { readAgentHookRemoteTextFile } from './agent-hook-sftp-text-reader' +import type { HooksConfig } from './installer-utils' +import { parseHooksJsonText } from './hooks-json-read' const DEFAULT_REMOTE_CONFIG_MODE = 0o600 const REMOTE_SFTP_OPERATION_TIMEOUT_MS = 10_000 @@ -31,19 +38,14 @@ export async function readHooksJsonRemote( ): Promise { let body: string try { - body = await readFile(sftp, remotePath) + body = await readFile(sftp, remotePath, AGENT_HOOK_CONFIG_MAX_BYTES) } catch (err) { if (isNoEntryError(err)) { return {} } throw err } - try { - const parsed = JSON.parse(body) - return isPlainObject(parsed) ? parsed : null - } catch { - return null - } + return parseHooksJsonText(body) } /** Atomically write a JSON config to the remote — write to a tmp path then @@ -62,11 +64,14 @@ export async function writeHooksJsonRemote( // Why: skip the write when on-disk content is identical so repeated // install() calls do not bump the file's mtime / inode unnecessarily. try { - const existing = await readFile(sftp, remotePath) + const existing = await readFile(sftp, remotePath, AGENT_HOOK_CONFIG_MAX_BYTES) if (existing === serialized) { return } - } catch { + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw error + } // ENOENT or read error — fall through to the write below. } // Why: tmp + rename so a partial network drop mid-write does not leave a @@ -98,12 +103,15 @@ export async function writeManagedScriptRemote( const dir = dirnamePosix(remotePath) await mkdirpRemote(sftp, dir) try { - const existing = await readFile(sftp, remotePath) + const existing = await readFile(sftp, remotePath, AGENT_HOOK_MANAGED_SCRIPT_MAX_BYTES) if (existing === content) { await chmod(sftp, remotePath, 0o755) return } - } catch { + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw error + } // ENOENT or read error — fall through to the atomic write below. } @@ -126,10 +134,11 @@ export async function writeManagedScriptRemote( export async function readTextFileRemote( sftp: SFTPWrapper, - remotePath: string + remotePath: string, + maxBytes = AGENT_HOOK_CONFIG_MAX_BYTES ): Promise { try { - return await readFile(sftp, remotePath) + return await readFile(sftp, remotePath, maxBytes) } catch (err) { if (isNoEntryError(err)) { return null @@ -141,16 +150,20 @@ export async function readTextFileRemote( export async function writeTextFileRemoteAtomic( sftp: SFTPWrapper, remotePath: string, - content: string + content: string, + maxExistingBytes = AGENT_HOOK_CONFIG_MAX_BYTES ): Promise { const dir = dirnamePosix(remotePath) await mkdirpRemote(sftp, dir) try { - const existing = await readFile(sftp, remotePath) + const existing = await readFile(sftp, remotePath, maxExistingBytes) if (existing === content) { return } - } catch { + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw error + } // ENOENT or read error — fall through to the atomic write below. } @@ -211,11 +224,8 @@ function sftpOperation( }) } -async function readFile(sftp: SFTPWrapper, remotePath: string): Promise { - const data = await sftpOperation(`readFile ${remotePath}`, (callback) => { - sftp.readFile(remotePath, 'utf8', callback) - }) - return typeof data === 'string' ? data : data.toString('utf8') +async function readFile(sftp: SFTPWrapper, remotePath: string, maxBytes: number): Promise { + return readAgentHookRemoteTextFile(sftp, remotePath, maxBytes, REMOTE_SFTP_OPERATION_TIMEOUT_MS) } async function writeFile( @@ -296,12 +306,6 @@ async function chmod(sftp: SFTPWrapper, remotePath: string, mode: number): Promi }) } -async function readdir(sftp: SFTPWrapper, remotePath: string): Promise { - return await sftpOperation(`readdir ${remotePath}`, (callback) => { - sftp.readdir(remotePath, callback) - }) -} - async function mkdir(sftp: SFTPWrapper, remotePath: string): Promise { await sftpOperation(`mkdir ${remotePath}`, (callback) => { sftp.mkdir(remotePath, callback) @@ -320,7 +324,7 @@ async function mkdirpRemote(sftp: SFTPWrapper, remotePath: string): Promise { }) describe('managed-hook local filesystem', () => { + it('uses the bounded local reader instead of the legacy whole-file callback', async () => { + const home = await createTempHome() + const configPath = join(home, 'oversized-config.json') + await writeFile(configPath, '123456789', 'utf8') + const filesystem = createManagedHookLocalFilesystem() + const legacyRead = vi.spyOn(filesystem, 'readFile') + + await expect( + readAgentHookRemoteTextFile(filesystem, configPath, 8, 1_000) + ).rejects.toBeInstanceOf(NodeFileReadTooLargeError) + expect(legacyRead).not.toHaveBeenCalled() + }) + + it('probes a directory without reading or retaining any child entries', async () => { + let closeCalls = 0 + let readCalls = 0 + const filesystem = createManagedHookLocalFilesystem({ + openDirectory(_path, callback) { + callback(null, { + close(closeCallback) { + closeCalls += 1 + closeCallback(null) + }, + read() { + readCalls += 1 + } + } as never) + } + }) + + const entries = await new Promise((resolve, reject) => { + filesystem.readdir('/directory-with-millions-of-entries', (error, value) => { + if (error) { + reject(error) + return + } + resolve(value ?? []) + }) + }) + + expect(entries).toEqual([]) + expect(closeCalls).toBe(1) + expect(readCalls).toBe(0) + }) + it('supports cold and warm aggregate installs without SFTP or temp-file residue', async () => { const home = await createTempHome() const filesystem = createManagedHookLocalFilesystem() diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.ts b/src/main/agent-hooks/managed-hook-local-filesystem.ts index 1a104a3a3b1..fb6a39f6acb 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.ts @@ -1,8 +1,23 @@ -import { chmod, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs' +import { chmod, mkdir, opendir, readFile, rename, stat, unlink, writeFile } from 'node:fs' import type { SFTPWrapper } from 'ssh2' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' type Callback = (error: Error | null, value?: T) => void +type DirectoryProbeHandle = { + close(callback: (error: NodeJS.ErrnoException | null) => void): void +} + +export type ManagedHookLocalFilesystemOptions = { + openDirectory?: ( + path: string, + callback: (error: NodeJS.ErrnoException | null, directory?: DirectoryProbeHandle) => void + ) => void +} + function asSftpError(error: NodeJS.ErrnoException): Error & { code: number } { const translated = new Error(error.message, { cause: error }) as Error & { code: number } translated.code = @@ -20,11 +35,41 @@ function finish(callback: Callback, error: NodeJS.ErrnoException | null, v /** The managed installers only need this small callback-style SFTP surface. * On the remote host it turns hundreds of WAN round trips into local fs calls. */ -export function createManagedHookLocalFilesystem(): SFTPWrapper { +export function createManagedHookLocalFilesystem( + options?: ManagedHookLocalFilesystemOptions +): SFTPWrapper { + const openDirectory = + options?.openDirectory ?? + (( + path: string, + callback: (error: NodeJS.ErrnoException | null, directory?: DirectoryProbeHandle) => void + ) => { + opendir(path, callback) + }) const adapter = { readFile(path: string, _encoding: string, callback: Callback): void { readFile(path, 'utf8', (error, contents) => finish(callback, error, contents)) }, + orcaReadFileWithinLimit( + path: string, + maxBytes: number, + callback: Callback + ): void { + void readNodeFileWithinLimit(path, maxBytes).then( + ({ buffer }) => callback(null, buffer), + (error: unknown) => { + if (error instanceof NodeFileReadTooLargeError) { + callback(error) + return + } + callback( + error instanceof Error + ? asSftpError(error as NodeJS.ErrnoException) + : new Error(String(error)) + ) + } + ) + }, writeFile( path: string, contents: string, @@ -37,9 +82,15 @@ export function createManagedHookLocalFilesystem(): SFTPWrapper { stat(path, (error, stats) => finish(callback, error, stats)) }, readdir(path: string, callback: Callback<[]>): void { - // Why: installers use readdir only as an existence check; names and - // attrs would allocate work that no caller consumes. - readdir(path, (error) => finish(callback, error, [])) + // Why: installers only probe directory existence; opening and closing + // avoids materializing every child name before returning the unused []. + openDirectory(path, (error, directory) => { + if (error || !directory) { + finish(callback, error ?? new Error('Directory probe returned no handle')) + return + } + directory.close((closeError) => finish(callback, closeError, [])) + }) }, mkdir(path: string, callback: Callback): void { mkdir(path, (error) => finish(callback, error)) diff --git a/src/main/agent-hooks/managed-hook-lock-claims.ts b/src/main/agent-hooks/managed-hook-lock-claims.ts index d3299c7bfd8..d01a6800b66 100644 --- a/src/main/agent-hooks/managed-hook-lock-claims.ts +++ b/src/main/agent-hooks/managed-hook-lock-claims.ts @@ -1,8 +1,10 @@ import { randomUUID } from 'node:crypto' -import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { opendir, rename, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { NodeFileReadTooLargeError } from '../../shared/memory-safety/node-bounded-file-reader' import { CLAIMED_OWNER_PATTERN, + MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE, claimedOwnerFileName, claimRecordFileName, hasCode, @@ -10,6 +12,7 @@ import { ownerFileName, parseClaim, parseOwner, + readManagedHookLockRecord, removeFileIfPresent, type ManagedHookLockClaim, type ManagedHookLockOwner @@ -33,13 +36,21 @@ async function findClaimedOwner( lockParent: string, ownerToken: string ): Promise<{ claimToken: string; path: string } | null | undefined> { - const matches = (await readdir(lockParent)).flatMap((entry) => { - const match = CLAIMED_OWNER_PATTERN.exec(entry) - return match?.[1] === ownerToken && match[2] - ? [{ claimToken: match[2], path: join(lockParent, entry) }] - : [] + const directory = await opendir(lockParent, { + bufferSize: MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE }) - return matches.length === 1 ? matches[0] : matches.length === 0 ? null : undefined + let claimedOwner: { claimToken: string; path: string } | null = null + for await (const entry of directory) { + const match = CLAIMED_OWNER_PATTERN.exec(entry.name) + if (match?.[1] !== ownerToken || !match[2]) { + continue + } + if (claimedOwner) { + return undefined + } + claimedOwner = { claimToken: match[2], path: join(lockParent, entry.name) } + } + return claimedOwner } async function resolveClaimSource( @@ -53,10 +64,13 @@ async function resolveClaimSource( > { const ownerPath = join(lockParent, ownerFileName(owner.token)) try { - return parseOwner(await readFile(ownerPath, 'utf8'), owner.token) + return parseOwner(await readManagedHookLockRecord(ownerPath), owner.token) ? { kind: 'ready', sourcePath: ownerPath } : { kind: 'unverifiable' } } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + return { kind: 'unverifiable' } + } if (!hasCode(error, 'ENOENT')) { throw error } @@ -73,12 +87,12 @@ async function resolveClaimSource( let priorClaim: ManagedHookLockClaim | null try { priorClaim = parseClaim( - await readFile(priorClaimRecordPath, 'utf8'), + await readManagedHookLockRecord(priorClaimRecordPath), owner.token, claimedOwner.claimToken ) } catch (error) { - if (hasCode(error, 'ENOENT')) { + if (hasCode(error, 'ENOENT') || error instanceof NodeFileReadTooLargeError) { return { kind: 'unverifiable' } } throw error diff --git a/src/main/agent-hooks/managed-hook-lock-records.ts b/src/main/agent-hooks/managed-hook-lock-records.ts index 555c2a1a521..6059e4a645d 100644 --- a/src/main/agent-hooks/managed-hook-lock-records.ts +++ b/src/main/agent-hooks/managed-hook-lock-records.ts @@ -1,8 +1,13 @@ import { randomUUID } from 'node:crypto' -import { link, lstat, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { link, lstat, opendir, rename, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' import { readManagedHookProcessIdentity } from './managed-hook-owner-identity' +export const MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE = 32 +export const MANAGED_HOOK_LOCK_CLEANUP_CONCURRENCY = 8 +export const MANAGED_HOOK_LOCK_RECORD_MAX_BYTES = 2 * 1024 * 1024 + const UUID_PATTERN = '[\\da-f]{8}-[\\da-f]{4}-[1-5][\\da-f]{3}-[89ab][\\da-f]{3}-[\\da-f]{12}' const OWNER_FILE_PATTERN = new RegExp( `^managed-hook-install\\.owner-(${UUID_PATTERN})\\.json$`, @@ -127,10 +132,16 @@ export async function removeFileIfPresent(path: string): Promise { } } +export async function readManagedHookLockRecord(path: string): Promise { + return (await readNodeFileWithinLimit(path, MANAGED_HOOK_LOCK_RECORD_MAX_BYTES)).buffer.toString( + 'utf8' + ) +} + export async function inspectManagedHookLock(lockPath: string): Promise { let rawOwner: string try { - rawOwner = await readFile(lockPath, 'utf8') + rawOwner = await readManagedHookLockRecord(lockPath) } catch (error) { if (hasCode(error, 'ENOENT')) { return { kind: 'missing' } @@ -193,7 +204,7 @@ async function cleanOwnerEntry(path: string, token: string, hostIdentity: string if (stats.nlink !== 1) { return } - const owner = parseOwner(await readFile(path, 'utf8'), token) + const owner = parseOwner(await readManagedHookLockRecord(path), token) if (!owner || owner.hostIdentity !== hostIdentity) { return } @@ -229,7 +240,7 @@ async function cleanLockEntry( if (!claimMatch?.[1] || !claimMatch[2]) { return } - const claim = parseClaim(await readFile(path, 'utf8'), claimMatch[1], claimMatch[2]) + const claim = parseClaim(await readManagedHookLockRecord(path), claimMatch[1], claimMatch[2]) if (!claim || claim.hostIdentity !== hostIdentity) { return } @@ -250,25 +261,59 @@ async function cleanLockEntry( } } +function isManagedHookLockRecord(entry: string): boolean { + return ( + OWNER_FILE_PATTERN.test(entry) || + OWNER_DRAFT_PATTERN.test(entry) || + CLAIMED_OWNER_PATTERN.test(entry) || + CLAIM_RECORD_PATTERN.test(entry) + ) +} + +async function cleanLockEntrySafely( + entry: string, + lockParent: string, + hostIdentity: string +): Promise { + try { + await cleanLockEntry(entry, lockParent, hostIdentity) + } catch (error) { + if (!hasCode(error, 'ENOENT')) { + console.warn('[agent-hooks] Failed to clean managed-hook lock file', error) + } + } +} + export async function cleanupManagedHookLockFiles( lockParent: string, hostIdentity: string ): Promise { - let entries: string[] + let directory: Awaited> try { - entries = await readdir(lockParent) + directory = await opendir(lockParent, { + bufferSize: MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE + }) } catch { return } - await Promise.all( - entries.map(async (entry) => { - try { - await cleanLockEntry(entry, lockParent, hostIdentity) - } catch (error) { - if (!hasCode(error, 'ENOENT')) { - console.warn('[agent-hooks] Failed to clean managed-hook lock file', error) - } + + const pending: string[] = [] + try { + for await (const entry of directory) { + if (!isManagedHookLockRecord(entry.name)) { + continue } - }) - ) + pending.push(entry.name) + if (pending.length === MANAGED_HOOK_LOCK_CLEANUP_CONCURRENCY) { + await Promise.all( + pending.splice(0).map((name) => cleanLockEntrySafely(name, lockParent, hostIdentity)) + ) + } + } + await Promise.all( + pending.splice(0).map((name) => cleanLockEntrySafely(name, lockParent, hostIdentity)) + ) + } catch { + // Directory enumeration is best-effort, matching the prior readdir behavior. + } } diff --git a/src/main/agent-hooks/managed-hook-owner-identity.test.ts b/src/main/agent-hooks/managed-hook-owner-identity.test.ts index ae8ac15dd12..136bad67c61 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.test.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.test.ts @@ -57,14 +57,34 @@ async function loadLinuxIdentity(fixture: LinuxIdentityFixture) { uid: process.getuid?.() ?? 0 } }) + const readNodeFileWithinLimit = vi.fn(async (path: string, maxBytes: number) => { + if (String(path).endsWith('host-id') && fixture.hostToken) { + if (Buffer.byteLength(fixture.hostToken, 'utf8') > maxBytes) { + throw new Error('file too large') + } + return { + buffer: Buffer.from(fixture.hostToken, 'utf8'), + stats: { size: Buffer.byteLength(fixture.hostToken, 'utf8') } + } + } + throw Object.assign(new Error(`unavailable path: ${String(path)}`), { code: 'ENOENT' }) + }) vi.doMock('node:fs/promises', async (importOriginal) => ({ ...(await importOriginal>()), lstat, mkdir, readFile, - readlink + readlink, + writeFile: vi.fn(async () => { + throw Object.assign(new Error('host-local storage unavailable'), { code: 'EACCES' }) + }) })) - return { identity: await import('./managed-hook-owner-identity'), readFile } + vi.doMock('../../shared/memory-safety/node-bounded-file-reader', () => ({ readNodeFileWithinLimit })) + return { + identity: await import('./managed-hook-owner-identity'), + readFile, + readNodeFileWithinLimit + } } afterEach(() => { @@ -76,10 +96,23 @@ afterEach(() => { } vi.unstubAllEnvs() vi.doUnmock('node:fs/promises') + vi.doUnmock('../../shared/memory-safety/node-bounded-file-reader') vi.resetModules() }) describe('managed hook owner identity', () => { + it('rejects an oversized durable host token before materializing it', async () => { + const { identity, readNodeFileWithinLimit } = await loadLinuxIdentity({ + hostToken: 'x'.repeat(1025) + }) + + await expect(identity.readManagedHookHostIdentity()).resolves.toMatch(/^runtime:/) + expect(readNodeFileWithinLimit).toHaveBeenCalledWith( + '/var/tmp/orca-managed-hooks-1000/host-id', + 1024 + ) + }) + it('uses durable host-local identity without requiring Linux machine identity files', async () => { vi.stubEnv('SSH_CONNECTION', '198.51.100.8 53100 10.0.0.7 2222') const { identity, readFile } = await loadLinuxIdentity({ diff --git a/src/main/agent-hooks/managed-hook-owner-identity.ts b/src/main/agent-hooks/managed-hook-owner-identity.ts index e9801c3c489..b9d57e9a65f 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' import { link, lstat, mkdir, readFile, readlink, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { promisify } from 'node:util' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' const execFileAsync = promisify(execFile) const runtimeHostIdentity = `runtime:${randomUUID()}` @@ -10,6 +11,7 @@ const runtimeProcessIdentity = `runtime:${randomUUID()}` let hostIdentityPromise: Promise | undefined let bootIdentityPromise: Promise | undefined const HOST_TOKEN_PATTERN = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i +const HOST_TOKEN_FILE_MAX_BYTES = 1024 function hasCode(error: unknown, code: string): boolean { return error instanceof Error && 'code' in error && error.code === code @@ -43,7 +45,8 @@ async function readPublishedHostToken(path: string, uid: number): Promise { expect(persist).toHaveBeenCalledTimes(1) expect(persist).toHaveBeenCalledWith([sibling, otherTab]) }) + + it('evicts the oldest unsupported PTY after the retention ceiling', () => { + for (let index = 0; index < MIGRATION_UNSUPPORTED_PTY_MAX_ENTRIES; index++) { + setMigrationUnsupportedPty(makeEntry(`pty-${index}`, `tab-${index}:leaf-a`)) + } + const listener = vi.fn() + const persist = vi.fn() + setMigrationUnsupportedPtyListener(listener) + setMigrationUnsupportedPtyPersistenceListener(persist) + const newest = makeEntry(`pty-${MIGRATION_UNSUPPORTED_PTY_MAX_ENTRIES}`, 'tab-newest:leaf-a') + + setMigrationUnsupportedPty(newest) + + const snapshot = getMigrationUnsupportedPtySnapshot() + expect(snapshot).toHaveLength(MIGRATION_UNSUPPORTED_PTY_MAX_ENTRIES) + expect(snapshot.some((entry) => entry.ptyId === 'pty-0')).toBe(false) + expect(snapshot.at(-1)).toEqual(newest) + expect(listener).toHaveBeenNthCalledWith(1, { type: 'clear', ptyId: 'pty-0' }) + expect(listener).toHaveBeenNthCalledWith(2, { type: 'set', entry: newest }) + expect(persist).toHaveBeenCalledTimes(1) + expect(persist).toHaveBeenCalledWith(snapshot) + }) + + it('rejects oversized entries and clears a prior value for the same PTY', () => { + const listener = vi.fn() + const persist = vi.fn() + setMigrationUnsupportedPtyListener(listener) + setMigrationUnsupportedPtyPersistenceListener(persist) + setMigrationUnsupportedPty(makeEntry('pty-1', 'tab-1:leaf-a')) + listener.mockClear() + persist.mockClear() + + setMigrationUnsupportedPty( + makeEntry('pty-1', `tab-1:${'x'.repeat(MIGRATION_UNSUPPORTED_PTY_MAX_ENTRY_BYTES)}`) + ) + + expect(getMigrationUnsupportedPtySnapshot()).toEqual([]) + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith({ type: 'clear', ptyId: 'pty-1' }) + expect(persist).toHaveBeenCalledOnce() + expect(persist).toHaveBeenCalledWith([]) + }) }) diff --git a/src/main/agent-hooks/migration-unsupported-pty-state.ts b/src/main/agent-hooks/migration-unsupported-pty-state.ts index 36bdc838569..876370a9ab9 100644 --- a/src/main/agent-hooks/migration-unsupported-pty-state.ts +++ b/src/main/agent-hooks/migration-unsupported-pty-state.ts @@ -1,10 +1,13 @@ import type { MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types' +import { stringifyJsonWithinByteLimit } from '../../shared/memory-safety/node-bounded-json-stringify' type MigrationUnsupportedPtyEvent = | { type: 'set'; entry: MigrationUnsupportedPtyEntry } | { type: 'clear'; ptyId: string } const entriesByPtyId = new Map() +export const MIGRATION_UNSUPPORTED_PTY_MAX_ENTRIES = 500 +export const MIGRATION_UNSUPPORTED_PTY_MAX_ENTRY_BYTES = 16 * 1024 let listener: ((event: MigrationUnsupportedPtyEvent) => void) | null = null let persistenceListener: ((entries: MigrationUnsupportedPtyEntry[]) => void) | null = null @@ -25,7 +28,21 @@ export function setMigrationUnsupportedPtyPersistenceListener( } export function setMigrationUnsupportedPty(entry: MigrationUnsupportedPtyEntry): void { + try { + stringifyJsonWithinByteLimit(entry, MIGRATION_UNSUPPORTED_PTY_MAX_ENTRY_BYTES) + } catch { + clearMigrationUnsupportedPty(entry.ptyId) + return + } entriesByPtyId.set(entry.ptyId, entry) + while (entriesByPtyId.size > MIGRATION_UNSUPPORTED_PTY_MAX_ENTRIES) { + const oldestPtyId = entriesByPtyId.keys().next().value + if (oldestPtyId === undefined) { + break + } + entriesByPtyId.delete(oldestPtyId) + listener?.({ type: 'clear', ptyId: oldestPtyId }) + } listener?.({ type: 'set', entry }) persistenceListener?.(getMigrationUnsupportedPtySnapshot()) } diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 6b4e12042c5..9fa87904af1 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -8,6 +8,7 @@ import { readFileSync, rmSync, statSync, + truncateSync, utimesSync, writeFileSync } from 'node:fs' @@ -17,6 +18,8 @@ import { AgentHookServer, agentHookServer, CLOSED_AGENT_STATUS_TAB_IDS_MAX, + MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES, + MAX_AGENT_HOOK_LAST_STATUS_STRUCTURAL_TOKENS, _internals } from './server' import { @@ -6617,6 +6620,46 @@ describe('Last-status persistence', () => { } }) + it('treats an oversized sparse last-status file as empty hydration', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync(lastStatusPath(), '{"version":2,"entries":{}}', 'utf8') + truncateSync(lastStatusPath(), MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES + 1) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith( + '[agent-hooks] failed to read last-status file:', + expect.any(Error) + ) + } finally { + server.stop() + warnSpy.mockRestore() + } + }) + + it('rejects structurally amplified last-status JSON before parsing', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const values = '0,'.repeat(MAX_AGENT_HOOK_LAST_STATUS_STRUCTURAL_TOKENS) + writeFileSync(lastStatusPath(), `[${values}0]`, 'utf8') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const parseSpy = vi.spyOn(JSON, 'parse') + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()).toEqual([]) + expect(parseSpy).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith( + '[agent-hooks] last-status file is invalid or too complex; ignoring' + ) + } finally { + server.stop() + warnSpy.mockRestore() + parseSpy.mockRestore() + } + }) + it('drops hydrated metadata-only entries without a resumable Pi session', async () => { mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) const receivedAt = recentTs() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 06dfb151560..18ce4e9bfa5 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -2,13 +2,19 @@ // 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. See docs/design/agent-status-over-ssh.md §5. 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 { chmodSync, mkdirSync, 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 } from '../../shared/agent-hook-types' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { assertJsonTextStructureWithinLimits } from '../../shared/memory-safety/json-text-structure-limit' +import { + JsonStringifyByteLimitError, + stringifyJsonWithinByteLimit +} from '../../shared/memory-safety/node-bounded-json-stringify' import { clearAllListenerCaches, clearPaneCacheState, @@ -35,6 +41,7 @@ import { type HookListenerState } from '../../shared/agent-hook-listener' import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { upsertBoundedAgentHookStatus } from '../../shared/agent-hook-status-cache' import { CLAUDE_STATUSLINE_PATHNAME, parseClaudeStatusLineBody, @@ -69,6 +76,7 @@ import { type AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' export type { AgentHookSource } @@ -96,6 +104,9 @@ type PaneKeyAliasEntry = { // 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' +export const MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES = 16 * 1024 * 1024 +export const MAX_AGENT_HOOK_LAST_STATUS_STRUCTURAL_TOKENS = 1_000_000 +export const MAX_AGENT_HOOK_LAST_STATUS_NESTING_DEPTH = 128 const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 @@ -115,6 +126,9 @@ const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 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 MAX_AGENT_HOOK_CONNECTION_TIMESTAMP_WATERMARKS = 1024 +export const MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES = 1024 +export const MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES = 16 * 1024 type LastStatusFile = { version: number @@ -181,6 +195,100 @@ function isValidPiProviderSessionOnly( return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession)) } +function isRetainedStringWithinByteLimit(value: string, maxBytes: number): boolean { + return !measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit +} + +function retainOptionalStringWithinByteLimit(value: unknown, maxBytes: number): string | undefined { + if ( + typeof value !== 'string' || + value.length === 0 || + !isRetainedStringWithinByteLimit(value, maxBytes) + ) { + return undefined + } + return value +} + +function normalizeOptionalRetainedString(value: unknown, maxBytes: number): string | undefined { + const retained = retainOptionalStringWithinByteLimit(value, maxBytes) + if (!retained) { + return undefined + } + const trimmed = retained.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function boundProviderSessionTranscriptPath( + providerSession: AgentProviderSessionMetadata | undefined +): AgentProviderSessionMetadata | undefined { + if (!providerSession?.transcriptPath) { + return providerSession + } + const transcriptPath = retainOptionalStringWithinByteLimit( + providerSession.transcriptPath, + MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES + ) + return transcriptPath === providerSession.transcriptPath + ? providerSession + : { key: providerSession.key, id: providerSession.id } +} + +function boundAgentHookEventMetadata(event: T): T { + const launchToken = retainOptionalStringWithinByteLimit( + event.launchToken, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const worktreeId = retainOptionalStringWithinByteLimit( + event.worktreeId, + MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES + ) + const promptInteractionKey = retainOptionalStringWithinByteLimit( + event.promptInteractionKey, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const hookEventName = retainOptionalStringWithinByteLimit( + event.hookEventName, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const toolUseId = retainOptionalStringWithinByteLimit( + event.toolUseId, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const toolAgentId = retainOptionalStringWithinByteLimit( + event.toolAgentId, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const toolAgentType = retainOptionalStringWithinByteLimit( + event.toolAgentType, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const providerSession = boundProviderSessionTranscriptPath(event.providerSession) + if ( + launchToken === event.launchToken && + worktreeId === event.worktreeId && + promptInteractionKey === event.promptInteractionKey && + hookEventName === event.hookEventName && + toolUseId === event.toolUseId && + toolAgentId === event.toolAgentId && + toolAgentType === event.toolAgentType && + providerSession === event.providerSession + ) { + return event + } + return { + ...event, + launchToken, + worktreeId, + promptInteractionKey, + hookEventName, + toolUseId, + toolAgentId, + toolAgentType, + providerSession + } +} + function sanitizeHydratedEntry( paneKey: string, rawEntry: unknown @@ -225,7 +333,10 @@ function sanitizeHydratedEntry( let connectionId: string | null if (connectionIdRaw === null || connectionIdRaw === undefined) { connectionId = null - } else if (typeof connectionIdRaw === 'string') { + } else if ( + typeof connectionIdRaw === 'string' && + isRetainedStringWithinByteLimit(connectionIdRaw, MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES) + ) { connectionId = connectionIdRaw } else { return null @@ -234,12 +345,14 @@ function sanitizeHydratedEntry( if (!payload) { return null } - const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined + const providerSession = boundProviderSessionTranscriptPath( + normalizeAgentProviderSession(record.providerSession) ?? undefined + ) const providerSessionOnly = record.providerSessionOnly === true if (providerSessionOnly && !isValidPiProviderSessionOnly(providerSession, payload.agentType)) { return null } - return { + return boundAgentHookEventMetadata({ paneKey, launchToken: typeof record.launchToken === 'string' ? record.launchToken : undefined, tabId: typeof tabId === 'string' ? tabId : undefined, @@ -255,7 +368,7 @@ function sanitizeHydratedEntry( payload, receivedAt, stateStartedAt - } + }) } function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload { @@ -750,6 +863,48 @@ export class AgentHookServer { } } + private cacheStatusEntry(entry: EnrichedAgentHookEventPayload, now = entry.receivedAt): number { + const boundedEntry = boundAgentHookEventMetadata(entry) + const evicted = upsertBoundedAgentHookStatus(this.state, boundedEntry, { now }) + for (const { paneKey } of evicted) { + this.clearAssistantMessageRetry(paneKey) + this.runtimeObservedStatusPaneKeys.delete(paneKey) + this.promptSentDedupeByPaneKey.delete(paneKey) + this.onPaneStatusCleared?.({ paneKey }) + } + return evicted.length + } + + private rememberConnectionTimestampWatermark(connectionId: string, timestamp: number): void { + this.connectionTimestampWatermarkById.delete(connectionId) + this.connectionTimestampWatermarkById.set(connectionId, timestamp) + if ( + this.connectionTimestampWatermarkById.size <= MAX_AGENT_HOOK_CONNECTION_TIMESTAMP_WATERMARKS + ) { + return + } + const activeConnectionIds = new Set() + for (const entry of this.state.lastStatusByPaneKey.values()) { + if (entry.connectionId) { + activeConnectionIds.add(entry.connectionId) + } + } + let oldestFallback: string | undefined + for (const retainedConnectionId of this.connectionTimestampWatermarkById.keys()) { + if (retainedConnectionId === connectionId) { + continue + } + oldestFallback ??= retainedConnectionId + if (!activeConnectionIds.has(retainedConnectionId)) { + this.connectionTimestampWatermarkById.delete(retainedConnectionId) + return + } + } + if (oldestFallback) { + this.connectionTimestampWatermarkById.delete(oldestFallback) + } + } + private hashPromptForTelemetryDedupe(prompt: string): string { return createHash('sha256') .update(this.promptSentHashSalt) @@ -819,7 +974,8 @@ export class AgentHookServer { } } - private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload { + private applyNormalizedStatus(rawPayload: AgentHookEventPayload): EnrichedAgentHookEventPayload { + const payload = boundAgentHookEventMetadata(rawPayload) const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined @@ -829,14 +985,14 @@ export class AgentHookServer { // Why: Date.now() can repeat across reconnect; a remote replay must sort strictly after its connection's transient clear. const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) if (payload.connectionId) { - this.connectionTimestampWatermarkById.set(payload.connectionId, now) + this.rememberConnectionTimestampWatermark(payload.connectionId, now) } if (payload.providerSessionOnly) { // Why: Pi session_start replaces stale turn state and survives replay, but must not emit prompt telemetry or a fabricated status. const enriched = this.attachStatusTiming(payload, now) this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.cacheStatusEntry(enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.onAgentStatus?.(enriched) @@ -948,7 +1104,7 @@ export class AgentHookServer { } const enriched = this.attachStatusTiming(effectivePayload, now) this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) - this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.cacheStatusEntry(enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.onAgentStatus?.(enriched) @@ -1320,10 +1476,16 @@ export class AgentHookServer { if (this.shouldSuppressClosedTabStatus(paneKey)) { return } - const worktreeId = - event.worktreeId !== undefined && event.worktreeId.trim().length > 0 - ? event.worktreeId.trim() - : undefined + const worktreeId = normalizeOptionalRetainedString( + event.worktreeId, + MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES + ) + if ( + typeof event.connectionId === 'string' && + !isRetainedStringWithinByteLimit(event.connectionId, MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES) + ) { + return + } const connectionId = typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 ? event.connectionId.trim() @@ -1375,6 +1537,9 @@ export class AgentHookServer { if (typeof connectionId !== 'string') { return } + if (!isRetainedStringWithinByteLimit(connectionId, MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES)) { + return + } const trimmedConnectionId = connectionId.trim() if (trimmedConnectionId.length === 0) { return @@ -1418,32 +1583,33 @@ export class AgentHookServer { if (this.shouldSuppressClosedTabStatus(paneKey)) { return } - const worktreeId = - envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 - ? envelope.worktreeId.trim() - : undefined - const hookEventName = - typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 - ? envelope.hookEventName.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 toolAgentType = - typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 - ? envelope.toolAgentType.trim() - : undefined - const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined + const worktreeId = normalizeOptionalRetainedString( + envelope.worktreeId, + MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES + ) + const hookEventName = normalizeOptionalRetainedString( + envelope.hookEventName, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const promptInteractionKey = normalizeOptionalRetainedString( + envelope.promptInteractionKey, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const toolUseId = normalizeOptionalRetainedString( + envelope.toolUseId, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const toolAgentId = normalizeOptionalRetainedString( + envelope.toolAgentId, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const toolAgentType = normalizeOptionalRetainedString( + envelope.toolAgentType, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ) + const providerSession = boundProviderSessionTranscriptPath( + normalizeAgentProviderSession(envelope.providerSession) ?? undefined + ) // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). const normalizedPayload = normalizeAgentStatusPayload(envelope.payload) if (!normalizedPayload) { @@ -1463,7 +1629,10 @@ export class AgentHookServer { }) const event: AgentHookEventPayload = { paneKey, - launchToken: envelope.launchToken, + launchToken: normalizeOptionalRetainedString( + envelope.launchToken, + MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + ), tabId, worktreeId, connectionId: trimmedConnectionId, @@ -1548,8 +1717,14 @@ export class AgentHookServer { trackEmptyPaneKeyHook(body) const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body) const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env) - if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) { - const enriched = this.applyNormalizedStatus(normalized) + const retained = normalized ? boundAgentHookEventMetadata(normalized) : null + if ( + retained && + (!retained.providerSessionOnly || + isValidPiProviderSessionOnly(retained.providerSession, retained.payload.agentType)) && + !this.shouldSuppressClosedTabStatus(retained.paneKey) + ) { + const enriched = this.applyNormalizedStatus(retained) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) } @@ -1626,6 +1801,9 @@ export class AgentHookServer { /** Clear statuses proven to belong to one lost SSH transport. */ clearStatusEntriesForConnection(connectionId: string): void { + if (!isRetainedStringWithinByteLimit(connectionId, MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES)) { + return + } const normalizedConnectionId = connectionId.trim() if (normalizedConnectionId.length === 0) { return @@ -1634,7 +1812,7 @@ export class AgentHookServer { Date.now(), (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 ) - this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) + this.rememberConnectionTimestampWatermark(normalizedConnectionId, clearedAt) let statusChanged = false for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { const entry = rawEntry as EnrichedAgentHookEventPayload @@ -1829,7 +2007,10 @@ export class AgentHookServer { this.state.lastStatusByPaneKey.clear() let raw: string try { - raw = readFileSync(this.lastStatusFilePath, 'utf8') + raw = readNodeFileSyncWithinLimit( + this.lastStatusFilePath, + MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES + ).buffer.toString('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') { @@ -1839,9 +2020,13 @@ export class AgentHookServer { } let parsed: unknown try { + assertJsonTextStructureWithinLimits(raw, { + structuralTokens: MAX_AGENT_HOOK_LAST_STATUS_STRUCTURAL_TOKENS, + nestingDepth: MAX_AGENT_HOOK_LAST_STATUS_NESTING_DEPTH + }) parsed = JSON.parse(raw) } catch { - console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') + console.warn('[agent-hooks] last-status file is invalid or too complex; ignoring') return } if (typeof parsed !== 'object' || parsed === null) { @@ -1866,7 +2051,8 @@ export class AgentHookServer { let dropped = 0 let prunedLegacyClaudeSubagents = 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 + const hydrateNow = Date.now() + const ttlCutoff = hydrateNow - HYDRATE_MAX_AGE_MS for (const [paneKey, rawEntry] of Object.entries(entries)) { const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) const rawResolvedEntry = @@ -1881,11 +2067,11 @@ export class AgentHookServer { (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) entry.payload = hydratedPayload } - this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) + dropped += this.cacheStatusEntry(entry, hydrateNow) 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( + this.rememberConnectionTimestampWatermark( entry.connectionId, Math.max(previousWatermark ?? -1, entry.receivedAt) ) @@ -1907,7 +2093,7 @@ export class AgentHookServer { } if (dropped > 0) { console.warn( - `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` + `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${this.state.lastStatusByPaneKey.size})` ) } if (dropped > 0 || prunedLegacyClaudeSubagents > 0) { @@ -1920,17 +2106,45 @@ export class AgentHookServer { } private serializeStatusFile(): string { - const entries: Record = {} - for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { + const prefix = `{"version":${LAST_STATUS_FILE_VERSION},"entries":{` + const suffix = '}}' + let remainingBytes = + MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES - + Buffer.byteLength(prefix, 'utf8') - + Buffer.byteLength(suffix, 'utf8') + const fragments: string[] = [] + const statuses = Array.from(this.state.lastStatusByPaneKey) + for (let index = statuses.length - 1; index >= 0; index -= 1) { + const [paneKey, payload] = statuses[index] // Why: never persist invalid keys (matches the hydrate-path invariant). if (!isValidPaneKey(paneKey)) { continue } const { promptInteractionKey: _promptInteractionKey, ...persistedPayload } = payload - entries[paneKey] = persistedPayload as EnrichedAgentHookEventPayload + const propertyPrefix = `${JSON.stringify(paneKey)}:` + const separatorBytes = fragments.length > 0 ? 1 : 0 + const payloadBudget = + remainingBytes - separatorBytes - Buffer.byteLength(propertyPrefix, 'utf8') + if (payloadBudget < 0) { + continue + } + let serializedPayload: string + try { + serializedPayload = stringifyJsonWithinByteLimit(persistedPayload, payloadBudget).serialized + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + continue + } + throw error + } + fragments.push(`${propertyPrefix}${serializedPayload}`) + remainingBytes -= + separatorBytes + + Buffer.byteLength(propertyPrefix, 'utf8') + + Buffer.byteLength(serializedPayload, 'utf8') } - const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries } - return JSON.stringify(file) + fragments.reverse() + return `${prefix}${fragments.join(',')}${suffix}` } private scheduleStatusPersist(): void { diff --git a/src/main/agent-hooks/wsl-guest-plugin-install.test.ts b/src/main/agent-hooks/wsl-guest-plugin-install.test.ts new file mode 100644 index 00000000000..11ec2cae20b --- /dev/null +++ b/src/main/agent-hooks/wsl-guest-plugin-install.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' + +import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' + +function fakeMux( + request: () => Promise, + isDisposed = false +): { mux: SshChannelMultiplexer } { + return { mux: { request, isDisposed: () => isDisposed } as unknown as SshChannelMultiplexer } +} + +function deps() { + return { + pluginSources: () => ({ opencodePluginSource: '// src' }), + warn: vi.fn<(message: string) => void>() + } +} + +describe('requestGuestOpenCodeOverlayDir', () => { + it('reports the guest overlay dir', async () => { + const { mux } = fakeMux(async () => ({ overlayDirs: { opencode: '/home/jin/.orca-relay/x' } })) + await expect(requestGuestOpenCodeOverlayDir(mux, deps(), 'Ubuntu')).resolves.toEqual({ + kind: 'dir', + dir: '/home/jin/.orca-relay/x' + }) + }) + + it("reports 'none' when the guest answered but materialization produced no dir", async () => { + // Why: distinct from 'unavailable' — the caller must CLEAR a previously recorded + // dir here, since a rebuild that failed after wiping leaves it plugin-less. + const { mux } = fakeMux(async () => ({ installed: { opencode: true }, overlayDirs: {} })) + await expect(requestGuestOpenCodeOverlayDir(mux, deps(), 'Ubuntu')).resolves.toEqual({ + kind: 'none' + }) + }) + + it("reports 'unavailable' for an older guest bundle and for teardown, without warning", async () => { + for (const code of [-32601, 'CONNECTION_LOST', 'DISPOSED']) { + const d = deps() + const { mux } = fakeMux(async () => { + throw Object.assign(new Error('nope'), { code }) + }) + await expect(requestGuestOpenCodeOverlayDir(mux, d, 'Ubuntu')).resolves.toEqual({ + kind: 'unavailable' + }) + expect(d.warn).not.toHaveBeenCalled() + } + }) + + it("warns but still reports 'unavailable' on an unexpected failure", async () => { + const d = deps() + const { mux } = fakeMux(async () => { + throw new Error('boom') + }) + await expect(requestGuestOpenCodeOverlayDir(mux, d, 'Ubuntu')).resolves.toEqual({ + kind: 'unavailable' + }) + expect(d.warn).toHaveBeenCalledWith(expect.stringContaining('boom')) + }) +}) diff --git a/src/main/agent-hooks/wsl-guest-plugin-install.ts b/src/main/agent-hooks/wsl-guest-plugin-install.ts new file mode 100644 index 00000000000..5d0d189e73f --- /dev/null +++ b/src/main/agent-hooks/wsl-guest-plugin-install.ts @@ -0,0 +1,45 @@ +// Ships plugin/extension source to the guest WSL relay and reports the OpenCode +// config-overlay dir it materialized. Best-effort: an older guest bundle lacks +// the handler (-32601) and routine teardown races resolve to `unavailable`. +// Mirrors the SSH relay's installPluginsOnRelay swallow list. +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' +import type { PluginSources } from '../../relay/plugin-overlay' + +/** Structural, not the deps type itself, so this stays free of the deps module. */ +type GuestPluginInstallDeps = { + pluginSources: () => PluginSources + warn: (message: string) => void +} + +/** `none` (guest answered, but materialization failed) must not be conflated + * with `unavailable` (no handler / teardown): only `none` means the previously + * recorded dir is now unusable and must stop being advertised to PTYs. */ +export type GuestOverlayResult = + | { kind: 'dir'; dir: string } + | { kind: 'none' } + | { kind: 'unavailable' } + +export async function requestGuestOpenCodeOverlayDir( + mux: SshChannelMultiplexer, + deps: GuestPluginInstallDeps, + distro: string +): Promise { + try { + const res = (await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, deps.pluginSources())) as { + overlayDirs?: { opencode?: unknown } + } + const dir = res?.overlayDirs?.opencode + return typeof dir === 'string' && dir.length > 0 ? { kind: 'dir', dir } : { kind: 'none' } + } catch (err) { + // Why: -32601 = older guest bundle without the handler; CONNECTION_LOST/DISPOSED = routine mid-flight teardown — swallow both. + const code = (err as { code?: unknown })?.code + if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED' || mux.isDisposed()) { + return { kind: 'unavailable' } + } + deps.warn( + `[agent-hooks] WSL installPlugins for '${distro}' failed: ${err instanceof Error ? err.message : String(err)}` + ) + return { kind: 'unavailable' } + } +} diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index d47dd400199..db6553b09de 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -5,10 +5,20 @@ // `installer-utils-remote.ts` touches are implemented. import type { SFTPWrapper } from 'ssh2' +import { assertFilesystemDirectoryWithinLimit } from '../../shared/filesystem-directory-listing-limit' +import { NodeFileReadTooLargeError } from '../../shared/memory-safety/node-bounded-file-reader' import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' -import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract' +import { + WSL_HOOK_FS_METHODS, + WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES, + WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES, + WSL_HOOK_FS_MAX_READ_BYTES, + type WslFsFailure, + type WslFsResult, + type WslHookFsDirectoryLimits +} from '../../shared/wsl-hook-relay-contract' /** Run the shared remote hook installers against a WSL guest over the relay's * fs bridge. Codex is the one agent whose home Orca redirects for WSL @@ -44,7 +54,23 @@ const ERRNO_TO_SFTP_CODE: Record = { EEXIST: 4 } -function toSftpError(failure: { errno?: string; message?: string }): Error { +function toSftpError(failure: { + errno?: string + message?: string + fileCapacity?: { observedBytes: number; maxBytes: number } +}): Error { + if ( + failure.errno === 'EFBIG' && + Number.isSafeInteger(failure.fileCapacity?.observedBytes) && + Number.isSafeInteger(failure.fileCapacity?.maxBytes) && + failure.fileCapacity!.observedBytes >= 0 && + failure.fileCapacity!.maxBytes >= 0 + ) { + return new NodeFileReadTooLargeError( + failure.fileCapacity!.observedBytes, + failure.fileCapacity!.maxBytes + ) + } const err = new Error(failure.message ?? 'wsl fs bridge failure') as Error & { code?: number } err.code = ERRNO_TO_SFTP_CODE[failure.errno ?? ''] ?? 5 return err @@ -62,7 +88,7 @@ export function createWslHookSftpAdapter(mux: SshChannelMultiplexer): SFTPWrappe .then((raw) => { const result = raw as WslFsResult if (!result || typeof result !== 'object' || result.ok !== true) { - callback(toSftpError((result ?? {}) as { errno?: string; message?: string })) + callback(toSftpError((result ?? {}) as WslFsFailure)) return } callback(null, pick(result)) @@ -81,7 +107,15 @@ export function createWslHookSftpAdapter(mux: SshChannelMultiplexer): SFTPWrappe readFile(path: string, _encoding: unknown, callback: SftpCallback): void { call<{ content: string }, string>( WSL_HOOK_FS_METHODS.readFile, - { path }, + { path, maxBytes: WSL_HOOK_FS_MAX_READ_BYTES }, + callback, + (r) => r.content + ) + }, + orcaReadFileWithinLimit(path: string, maxBytes: number, callback: SftpCallback): void { + call<{ content: string }, string>( + WSL_HOOK_FS_METHODS.readFile, + { path, maxBytes }, callback, (r) => r.content ) @@ -122,9 +156,45 @@ export function createWslHookSftpAdapter(mux: SshChannelMultiplexer): SFTPWrappe readdir(path: string, callback: SftpCallback<{ filename: string }[]>): void { call<{ entries: { filename: string }[] }, { filename: string }[]>( WSL_HOOK_FS_METHODS.readdir, - { path }, + { + path, + maxEntries: WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES, + maxRetainedBytes: WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES + }, callback, - (r) => r.entries + (r) => { + const limits = { + maxEntries: WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES, + maxRetainedBytes: WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES + } + assertFilesystemDirectoryWithinLimit( + r.entries.map((entry) => ({ name: entry.filename })), + limits + ) + return r.entries + } + ) + }, + orcaReaddirWithinLimit( + path: string, + limits: WslHookFsDirectoryLimits, + callback: SftpCallback<{ filename: string }[]> + ): void { + call<{ entries: { filename: string }[] }, { filename: string }[]>( + WSL_HOOK_FS_METHODS.readdir, + { + path, + maxEntries: limits.maxEntries, + maxRetainedBytes: limits.maxRetainedBytes + }, + callback, + (r) => { + assertFilesystemDirectoryWithinLimit( + r.entries.map((entry) => ({ name: entry.filename })), + limits + ) + return r.entries + } ) }, mkdir(path: string, callback: SftpCallback): void { diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index 2c30739e25b..cbeb5477d9b 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -6,6 +6,8 @@ import { readFileSync } from 'node:fs' import { agentHookServer } from './server' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' +import { getOpenCodePluginSource } from '../opencode/hook-service' +import type { PluginSources } from '../../relay/plugin-overlay' import { isWslDistroRunning, resolveWslHookRelayBundle, @@ -57,6 +59,8 @@ export type WslHookRelayManagerDeps = { waitForSentinel: typeof waitForWslRelaySentinel ingest: (envelope: Record, connectionId: string) => void installHooks: typeof installRemoteManagedAgentHooks + /** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */ + pluginSources: () => PluginSources warn: (message: string) => void transientRetryDelayMs: number } @@ -85,6 +89,8 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { connectionId ), installHooks: installRemoteManagedAgentHooks, + // Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later. + pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }), warn: (message) => console.warn(message), transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS } diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 7e341d408da..0eaf850b63a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -16,6 +16,7 @@ import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers import { WslHookRelayManager } from './wsl-hook-relay-manager' import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' import { + AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay' @@ -135,6 +136,7 @@ describe('WslHookRelayManager', () => { // hosts — installHooks is mocked here, so the fs bridge only ever serves // the wslfs.home request and never touches the real filesystem. const home = '/home/wsl-test-user' + const opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe` let harnesses: GuestHarness[] beforeEach(() => { @@ -164,13 +166,20 @@ describe('WslHookRelayManager', () => { return child as unknown as ChildProcessWithoutNullStreams & { emitClose: () => void } } - function guestTransport(): MultiplexerTransport { + function guestTransport(registerInstallPlugins = true): MultiplexerTransport { const harness = createGuestHarness() harnesses.push(harness) registerWslHookFsHandlers(harness.guestDispatcher, home) harness.guestDispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({ replayed: 0 })) + // A guest bundle predating the plugin overlay omits this handler (-32601). + if (registerInstallPlugins) { + harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({ + installed: { opencode: true, pi: false, omp: false }, + overlayDirs: { opencode: opencodeOverlayDir } + })) + } return harness.transport } @@ -203,6 +212,7 @@ describe('WslHookRelayManager', () => { waitForSentinel: vi.fn(async () => guestTransport()), ingest: vi.fn(), installHooks: vi.fn(async () => []), + pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }), warn: vi.fn(), transientRetryDelayMs: 1, ...overrides @@ -243,6 +253,25 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) + it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => { + const { manager } = createManager({}) + manager.ensureForDistro('Ubuntu') + await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir)) + manager.disposeAll() + }) + + it('leaves the overlay dir null when the guest bundle lacks the installPlugins handler', async () => { + const waitForSentinel = vi.fn(async () => guestTransport(false)) + const { manager, deps } = createManager({ waitForSentinel }) + manager.ensureForDistro('Ubuntu') + // Connect still completes (hooks install); the -32601 is swallowed silently. + await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBeNull() + expect(deps.warn).not.toHaveBeenCalledWith(expect.stringContaining('installPlugins')) + manager.disposeAll() + }) + it('resolves the default distro for null and dedupes it with the explicit name', async () => { const { manager, deps } = createManager({}) manager.ensureForDistro(null) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 0c70d3a48c9..0d3817791f7 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -18,6 +18,7 @@ import { } from './wsl-hook-relay-deps' import { wireWslRelayLink } from './wsl-hook-relay-link' import { WslRelayRecovery } from './wsl-hook-relay-recovery' +import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay' import { @@ -34,6 +35,7 @@ type DistroState = { mux?: SshChannelMultiplexer guestHome?: string guestEndpointFilePath?: string + opencodeOverlayDir?: string failures: number cooldownUntil: number connectedAt?: number @@ -85,14 +87,22 @@ export class WslHookRelayManager { }) } + private stateFor(distro: string | null): DistroState | undefined { + // Empty key never matches a real (non-empty) distro state. + return this.states.get(distroKey(distro ?? this.defaultDistro ?? '')) + } + /** Guest endpoint file path once known; null before first connect * (callers keep the /p-translated Windows endpoint path until then). */ getGuestEndpointFilePath(distro: string | null): string | null { - const name = distro ?? this.defaultDistro - if (!name) { - return null - } - return this.states.get(distroKey(name))?.guestEndpointFilePath ?? null + return this.stateFor(distro)?.guestEndpointFilePath ?? null + } + + /** Guest OpenCode config-overlay dir once the guest relay materializes it; + * null before then (older bundle / relay not yet connected). Callers drop + * OPENCODE_CONFIG_DIR while null so no Windows overlay path crosses into WSL. */ + getOpenCodeOverlayDir(distro: string | null): string | null { + return this.stateFor(distro)?.opencodeOverlayDir ?? null } disposeAll(): void { @@ -145,6 +155,9 @@ export class WslHookRelayManager { distro, phase: 'starting', failures: existing?.failures ?? 0, + // Why: instance-keyed and on the distro's persistent fs, so it outlives a relay + // crash — dropping it would blank status on panes spawned mid-relaunch. + opencodeOverlayDir: existing?.opencodeOverlayDir, cooldownUntil: 0 } this.states.set(key, state) @@ -169,7 +182,9 @@ export class WslHookRelayManager { { cooldownBaseMs: NO_NODE_COOLDOWN_MS } ), onFailure: (message) => - this.markFailed(state, message, { cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS }), + this.markFailed(state, message, { + cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS + }), connect: (transport, child) => this.connect(state, transport, child, instanceKey) }) } catch (err) { @@ -267,6 +282,14 @@ export class WslHookRelayManager { installHooks: this.deps.installHooks, warn: this.deps.warn }) + // Why: ship OpenCode's status plugin and record the guest overlay dir the + // PTY env points OPENCODE_CONFIG_DIR at; identity-guarded against teardown. + const overlay = await requestGuestOpenCodeOverlayDir(mux, this.deps, state.distro) + if (state.mux === mux && overlay.kind !== 'unavailable') { + // Clearing on 'none' matters: a rebuild that failed after wiping leaves the dir + // present but plugin-less, and advertising it would hide the user's own config. + state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined + } } private async maybeReinstallHooks(state: DistroState): Promise { @@ -281,6 +304,7 @@ export class WslHookRelayManager { return } try { + // Why: runInstallers also re-ships the plugin source so a mid-session Orca upgrade refreshes it. await this.runInstallers(state, mux, guestHome) } catch (err) { this.deps.warn( diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts index a8085485553..bb78100c6ea 100644 --- a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts @@ -5,7 +5,11 @@ import { EventEmitter } from 'node:events' import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { afterEach, describe, expect, it, vi } from 'vitest' -import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol' +import { + MAX_BUFFERED_FRAME_CHUNKS, + RELAY_SENTINEL, + RELAY_SENTINEL_TIMEOUT_MS +} from '../ssh/relay-protocol' import { MAX_STARTUP_BUFFER_BYTES, waitForWslRelaySentinel, @@ -110,6 +114,26 @@ describe('waitForWslRelaySentinel', () => { expect(received).toEqual(['DEFER']) }) + it('closes instead of retaining too many post-sentinel fragments before attachment', async () => { + const child = fakeChild() + const promise = waitForWslRelaySentinel(child) + emitStdout(child, RELAY_SENTINEL) + for (let index = 0; index <= MAX_BUFFERED_FRAME_CHUNKS; index += 1) { + emitStdout(child, Buffer.from('x')) + } + + const transport = await promise + const onClose = vi.fn() + const onData = vi.fn() + transport.onClose(onClose) + transport.onData(onData) + await Promise.resolve() + + expect(child.kill).toHaveBeenCalled() + expect(onClose).toHaveBeenCalledOnce() + expect(onData).not.toHaveBeenCalled() + }) + it('kills the child and rejects when startup output exceeds 64 KiB before the sentinel', async () => { const child = fakeChild() const settled = catchStartup(waitForWslRelaySentinel(child)) diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts index fdabf70fe15..19a2b9a160d 100644 --- a/src/main/agent-hooks/wsl-hook-relay-sentinel.ts +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts @@ -4,10 +4,17 @@ // ChildProcess instead of a ClientChannel. import type { ChildProcessWithoutNullStreams } from 'node:child_process' -import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol' +import { + HEADER_LENGTH, + MAX_BUFFERED_FRAME_CHUNKS, + MAX_MESSAGE_SIZE, + RELAY_SENTINEL, + RELAY_SENTINEL_TIMEOUT_MS +} from '../ssh/relay-protocol' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' export const MAX_STARTUP_BUFFER_BYTES = 64 * 1024 +const MAX_PENDING_RELAY_BYTES = (MAX_MESSAGE_SIZE + HEADER_LENGTH) * 2 // Why: without WSL_UTF8, wsl.exe's own messages arrive UTF-16LE; NUL bytes // in breadcrumbs and the catastrophic-failure matcher must not depend on the @@ -45,6 +52,7 @@ export function waitForWslRelaySentinel( // decoder never sees chunks out of order. A setImmediate handoff would // NOT preserve that: it is a macrotask the next 'data' event can beat. const pendingChunks: Buffer[] = [] + let pendingChunkBytes = 0 let closedNotified = false const fail = (failure: WslRelayStartupFailure): void => { @@ -71,8 +79,22 @@ export function waitForWslRelaySentinel( } const dispatch = (chunk: Buffer): void => { + if (closedNotified) { + return + } if (dataCallbacks.length === 0) { + if ( + chunk.length > MAX_PENDING_RELAY_BYTES - pendingChunkBytes || + pendingChunks.length >= MAX_BUFFERED_FRAME_CHUNKS + ) { + pendingChunks.length = 0 + pendingChunkBytes = 0 + notifyClosed() + child.kill() + return + } pendingChunks.push(chunk) + pendingChunkBytes += chunk.length return } for (const cb of dataCallbacks) { @@ -104,23 +126,32 @@ export function waitForWslRelaySentinel( dispatch(chunk) return } - stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) - const idx = stdoutBuffer.indexOf(sentinel) + const searchableLength = Math.max( + 0, + MAX_STARTUP_BUFFER_BYTES - stdoutBuffer.length + sentinel.length + ) + const searchableChunk = chunk.subarray(0, searchableLength) + const startupOutput = + stdoutBuffer.length === 0 ? searchableChunk : Buffer.concat([stdoutBuffer, searchableChunk]) + const idx = startupOutput.indexOf(sentinel) if (idx === -1) { // Why: pre-sentinel stdout is untrusted startup noise; cap it so a // broken guest cannot grow memory until the timeout fires. - if (stdoutBuffer.length > MAX_STARTUP_BUFFER_BYTES) { + if (startupOutput.length > MAX_STARTUP_BUFFER_BYTES) { child.kill() fail({ kind: 'exit', code: null, stderr: 'startup output exceeded 64 KiB' }) + } else { + stdoutBuffer = startupOutput } return } sentinelSeen = true settled = true clearTimeout(timeout) - const trailing = stdoutBuffer.subarray(idx + sentinel.length) + const trailingOffset = idx + sentinel.length - stdoutBuffer.length + const trailing = chunk.subarray(Math.max(0, trailingOffset)) if (trailing.length > 0) { - pendingChunks.push(trailing) + dispatch(trailing) } const transport: MultiplexerTransport = { write: (data) => { @@ -135,6 +166,7 @@ export function waitForWslRelaySentinel( if (dataCallbacks.length === 1 && pendingChunks.length > 0) { queueMicrotask(() => { for (const pending of pendingChunks.splice(0)) { + pendingChunkBytes -= pending.length for (const dataCb of dataCallbacks) { dataCb(pending) } @@ -142,7 +174,13 @@ export function waitForWslRelaySentinel( }) } }, - onClose: (cb) => closeCallbacks.push(cb), + onClose: (cb) => { + if (closedNotified) { + queueMicrotask(cb) + } else { + closeCallbacks.push(cb) + } + }, close: () => child.kill() } resolve(transport) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 2a979c60e27..e89272696a6 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -6,11 +6,14 @@ import { readFileSync, realpathSync, rmSync, + statSync, + truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_AGENT_STATE_FILE_BYTES } from './agent-state-file-reader' const testState = { fakeHomeDir: '', @@ -38,8 +41,12 @@ vi.mock('node:os', async () => { } }) -const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } = - await import('./agent-trust-presets') +const { + MAX_AGENT_TRUST_GIT_POINTER_BYTES, + markCodexProjectTrusted, + markCopilotFolderTrusted, + markCursorWorkspaceTrusted +} = await import('./agent-trust-presets') beforeEach(() => { testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) @@ -134,9 +141,115 @@ describe('markCopilotFolderTrusted', () => { rmSync(workspace, { recursive: true, force: true }) } }) + + it('does not overwrite a sparse config above the 4 MiB read ceiling', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const configDir = join(testState.fakeHomeDir, '.copilot') + const configPath = join(configDir, 'config.json') + try { + mkdirSync(configDir, { recursive: true }) + writeFileSync(configPath, '{"keep":true}') + truncateSync(configPath, MAX_AGENT_STATE_FILE_BYTES + 1) + + markCopilotFolderTrusted(workspace) + + expect(statSync(configPath).size).toBe(MAX_AGENT_STATE_FILE_BYTES + 1) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) }) describe('markCodexProjectTrusted', () => { + it('accepts both linked-worktree pointers at the exact 64 KiB boundary', () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-exact-git-pointers-')) + const repository = join(fixtureRoot, 'repo') + const workspace = join(fixtureRoot, 'worktrees', 'feature') + const worktreeGitDir = join(repository, '.git', 'worktrees', 'feature') + const padPointer = (value: string): string => + value + ' '.repeat(MAX_AGENT_TRUST_GIT_POINTER_BYTES - Buffer.byteLength(value)) + try { + mkdirSync(worktreeGitDir, { recursive: true }) + mkdirSync(workspace, { recursive: true }) + const gitPointer = padPointer(`gitdir: ${worktreeGitDir}\n`) + const backlink = padPointer(`${join(workspace, '.git')}\n`) + expect(Buffer.byteLength(gitPointer)).toBe(MAX_AGENT_TRUST_GIT_POINTER_BYTES) + expect(Buffer.byteLength(backlink)).toBe(MAX_AGENT_TRUST_GIT_POINTER_BYTES) + writeFileSync(join(workspace, '.git'), gitPointer) + writeFileSync(join(worktreeGitDir, 'gitdir'), backlink) + + markCodexProjectTrusted(workspace) + + const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf8') + expect(written).toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(repository))}"]` + ) + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }) + } + }) + + it('falls back to the workspace when its sparse .git pointer exceeds 64 KiB', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-large-git-pointer-')) + try { + const gitPointerPath = join(workspace, '.git') + writeFileSync(gitPointerPath, '') + truncateSync(gitPointerPath, MAX_AGENT_TRUST_GIT_POINTER_BYTES + 1) + + markCodexProjectTrusted(workspace) + + const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf8') + expect(written).toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]` + ) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('falls back to the workspace when the sparse gitdir backlink exceeds 64 KiB', () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-large-gitdir-pointer-')) + const repository = join(fixtureRoot, 'repo') + const workspace = join(fixtureRoot, 'worktrees', 'feature') + const worktreeGitDir = join(repository, '.git', 'worktrees', 'feature') + try { + mkdirSync(worktreeGitDir, { recursive: true }) + mkdirSync(workspace, { recursive: true }) + writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`) + const backlinkPath = join(worktreeGitDir, 'gitdir') + writeFileSync(backlinkPath, '') + truncateSync(backlinkPath, MAX_AGENT_TRUST_GIT_POINTER_BYTES + 1) + + markCodexProjectTrusted(workspace) + + const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf8') + expect(written).toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]` + ) + expect(written).not.toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(repository))}"]` + ) + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }) + } + }) + + it('does not overwrite a sparse config above the 4 MiB read ceiling', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) + const configDir = join(testState.fakeHomeDir, '.codex') + const configPath = join(configDir, 'config.toml') + try { + mkdirSync(configDir, { recursive: true }) + writeFileSync(configPath, 'model = "keep"\n') + truncateSync(configPath, MAX_AGENT_STATE_FILE_BYTES + 1) + + expect(() => markCodexProjectTrusted(workspace)).toThrow('File too large') + expect(statSync(configPath).size).toBe(MAX_AGENT_STATE_FILE_BYTES + 1) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + it('trusts the main repository root for a linked worktree without reading commondir', () => { const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-')) const repository = join(fixtureRoot, 'repo') diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 3b441d6d1fe..ece72f7af87 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,11 +1,14 @@ -import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' +import { existsSync, mkdirSync, realpathSync } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' +import { readNodeFileSyncWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import { readAgentStateJsonFileSync } from './agent-state-file-reader' import { writeFileAtomically } from './codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' +export const MAX_AGENT_TRUST_GIT_POINTER_BYTES = 64 * 1024 /** * Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or @@ -73,8 +76,7 @@ export function markCopilotFolderTrusted(workspacePath: string): void { let config: Record = {} try { if (existsSync(configPath)) { - const raw = readFileSync(configPath, 'utf-8') - const parsed = JSON.parse(raw) + const parsed = readAgentStateJsonFileSync(configPath) if (parsed && typeof parsed === 'object') { config = parsed as Record } @@ -120,7 +122,7 @@ export function markCodexProjectTrusted(workspacePath: string): void { function resolveCodexProjectTrustRoot(workspacePath: string): string { const absPath = canonicalize(workspacePath) try { - const gitDirReference = readFileSync(join(absPath, '.git'), 'utf-8').trim() + const gitDirReference = readGitPointerFile(join(absPath, '.git')) if (!gitDirReference.startsWith('gitdir:')) { return absPath } @@ -134,7 +136,7 @@ function resolveCodexProjectTrustRoot(workspacePath: string): string { return absPath } // Why: workspace-controlled .git metadata must not broaden trust without Git's reciprocal link. - const gitDirBacklink = readFileSync(join(gitDir, 'gitdir'), 'utf-8').trim() + const gitDirBacklink = readGitPointerFile(join(gitDir, 'gitdir')) if (!gitDirBacklink) { return absPath } @@ -153,6 +155,12 @@ function resolveCodexProjectTrustRoot(workspacePath: string): string { } } +function readGitPointerFile(filePath: string): string { + return readNodeFileSyncWithinLimit(filePath, MAX_AGENT_TRUST_GIT_POINTER_BYTES) + .buffer.toString('utf8') + .trim() +} + function canonicalize(p: string): string { // Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but // both Cursor and Copilot's trust comparators run realpath() before the diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 4b39e75eb72..3943e0cb591 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -3,6 +3,7 @@ import { scanAiVaultSessions } from './session-scanner' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { aiVaultSessionListCacheKey, boundAiVaultListResult } from './session-list-retention' // Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime // RPC method share a single cache instance — opening the desktop panel and the @@ -34,10 +35,23 @@ export function configureAiVaultSessionSources(next: AiVaultSessionSources): voi export async function listAiVaultSessions(args?: AiVaultListArgs): Promise { // Scope paths change the result set, so they must be part of the cache key. - const key = JSON.stringify({ + const key = aiVaultSessionListCacheKey({ limit: args?.limit ?? 'default', scopePaths: args?.scopePaths ?? [] }) + const scan = async (): Promise => + scanAiVaultSessions({ + limit: args?.limit, + scopePaths: args?.scopePaths, + additionalCodexSessionsDirs: + sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [], + wslHomeDirs: await getAiVaultWslHomeDirs(), + // The shared result is restamped at the RPC edge for runtime callers. + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + if (key === null) { + return boundAiVaultListResult(await scan()) + } const now = Date.now() // Why: opening this panel repeatedly should not re-parse hundreds of JSONL // transcripts; explicit refreshes bypass the cache but not an active scan. @@ -49,25 +63,15 @@ export async function listAiVaultSessions(args?: AiVaultListArgs): Promise join(homePath, 'sessions')) ?? [] - inflightList = (async () => - scanAiVaultSessions({ - limit: args?.limit, - scopePaths: args?.scopePaths, - additionalCodexSessionsDirs, - wslHomeDirs: await getAiVaultWslHomeDirs(), - // Why: this scan is always host-local; callers addressing this host by a - // runtime id get the result restamped at the RPC edge, never rescanned. - executionHostId: LOCAL_EXECUTION_HOST_ID - }))() + inflightList = scan() .then((result) => { + const bounded = boundAiVaultListResult(result) cachedList = { key, - result, + result: bounded, expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS } - return result + return bounded }) .finally(() => { // Only clear tracking if it still refers to this request: a concurrent diff --git a/src/main/ai-vault/claude-subagent-list-retention.ts b/src/main/ai-vault/claude-subagent-list-retention.ts new file mode 100644 index 00000000000..94ab1e9c691 --- /dev/null +++ b/src/main/ai-vault/claude-subagent-list-retention.ts @@ -0,0 +1,19 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + AI_VAULT_SESSION_LIST_MAX_JSON_BYTES, + retainAiVaultSessionsWithinAggregate +} from './session-list-retention' +import { sessionSortTime } from './session-scanner-accumulator' + +export function retainClaudeSubagentSessionBatch( + retainedSessions: readonly AiVaultSession[], + batch: readonly (AiVaultSession | null)[], + maxBytes: number = AI_VAULT_SESSION_LIST_MAX_JSON_BYTES +): { sessions: AiVaultSession[]; omitted: number } { + const candidates = [ + ...retainedSessions, + ...batch.filter((session): session is AiVaultSession => session !== null) + ] + candidates.sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + return retainAiVaultSessionsWithinAggregate(candidates, maxBytes) +} diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts index d6a305ba4cf..901d28c8681 100644 --- a/src/main/ai-vault/remote-session-scanner-codex-index.ts +++ b/src/main/ai-vault/remote-session-scanner-codex-index.ts @@ -2,6 +2,11 @@ import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' +import { + CODEX_SESSION_INDEX_CACHE_KEY_MAX_UTF8_BYTES, + CODEX_SESSION_INDEX_TITLE_CACHE_MAX, + retainCodexSessionIndexTitle +} from './session-scanner-codex-title-index' const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' @@ -11,12 +16,25 @@ export async function remoteCodexIndexTitles(args: { hostPlatform: RemoteHostPlatform titleCaches: Map>> }): Promise> { - const cached = args.titleCaches.get(args.codexHome) + const cacheable = + Buffer.byteLength(args.codexHome, 'utf8') <= CODEX_SESSION_INDEX_CACHE_KEY_MAX_UTF8_BYTES + const cached = cacheable ? args.titleCaches.get(args.codexHome) : undefined if (cached) { + args.titleCaches.delete(args.codexHome) + args.titleCaches.set(args.codexHome, cached) return cached } const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform) - args.titleCaches.set(args.codexHome, pending) + if (cacheable) { + args.titleCaches.set(args.codexHome, pending) + while (args.titleCaches.size > CODEX_SESSION_INDEX_TITLE_CACHE_MAX) { + const oldest = args.titleCaches.keys().next().value + if (oldest === undefined) { + break + } + args.titleCaches.delete(oldest) + } + } return pending } @@ -41,7 +59,7 @@ async function readRemoteCodexIndexTitles( const sessionId = extractString(record.id) const title = normalizeTitleText(extractString(record.thread_name) ?? '') if (sessionId && title) { - titleBySessionId.set(sessionId, title) + retainCodexSessionIndexTitle(titleBySessionId, sessionId, title) } } } catch { diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 14a47f36559..2ec43644d82 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -6,6 +6,7 @@ import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +import { AiVaultSessionDiscoveryCapacityError } from './session-discovery-budget' import type { RemoteScannerContext, RemoteSessionCandidate, @@ -13,15 +14,31 @@ import type { } from './remote-session-scanner-types' const REMOTE_DISCOVERY_CONCURRENCY = 8 +const REMOTE_DISCOVERY_ISSUE_MAX = 256 export async function discoverRemoteSourceCandidates(args: { source: RemoteSessionSource context: RemoteScannerContext issues: AiVaultScanIssue[] }): Promise { - const walked = args.source.fixedChildFileSegments - ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) - : await walkRemoteSessionFiles(args.source, args.context, args.issues) + const walked: string[] = [] + try { + await (args.source.fixedChildFileSegments + ? collectRemoteFixedChildFiles(args.source, args.context, args.issues, walked) + : collectRemoteSessionFiles(args.source, args.context, args.issues, walked)) + } catch (error) { + if (error instanceof AiVaultSessionDiscoveryCapacityError) { + recordRemoteDirectoryIssue( + args.source, + args.context.executionHostId, + args.issues, + args.source.rootDir, + error + ) + } else { + throw error + } + } const partition = args.source.collectSubagentSiblingCounts ? partitionSubagentTranscriptPaths(walked) : null @@ -45,52 +62,63 @@ export async function discoverRemoteSourceCandidates(args: { })) } -async function listRemoteFixedChildFiles( +async function collectRemoteFixedChildFiles( source: RemoteSessionSource, context: RemoteScannerContext, - issues: AiVaultScanIssue[] -): Promise { + issues: AiVaultScanIssue[], + paths: string[] +): Promise { + context.discoveryBudget.enterDirectory(0) let entries try { entries = await context.provider.readDir(source.rootDir) } catch (err) { recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) - return [] + return } const segments = source.fixedChildFileSegments ?? [] // Why: Antigravity's transcript path is fixed. Constructing it avoids three // serialized SSH readDir round trips for every conversation directory. - return entries - .filter((entry) => entry.isDirectory && !entry.isSymlink) - .map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments)) - .filter((path) => source.filePredicate?.(path) ?? true) + for (const entry of entries) { + const entryPath = joinRemotePath(context.hostPlatform, source.rootDir, entry.name) + context.discoveryBudget.visitEntry(entryPath) + if (!entry.isDirectory || entry.isSymlink) { + continue + } + const path = joinRemotePath(context.hostPlatform, entryPath, ...segments) + if (source.filePredicate?.(path) ?? true) { + paths.push(path) + } + } } -async function walkRemoteSessionFiles( +async function collectRemoteSessionFiles( source: RemoteSessionSource, context: RemoteScannerContext, issues: AiVaultScanIssue[], + files: string[], dirPath = source.rootDir, depth = 0 -): Promise { +): Promise { + context.discoveryBudget.enterDirectory(depth) let entries try { entries = await context.provider.readDir(dirPath) } catch (err) { recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) - return [] + return } const extensions = new Set(source.extensions) - const files: string[] = [] for (const entry of entries) { const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) + context.discoveryBudget.visitEntry(fullPath) if ( entry.isDirectory && !entry.isSymlink && (source.directoryPredicate?.(entry.name, depth) ?? true) ) { - files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1))) + await collectRemoteSessionFiles(source, context, issues, files, fullPath, depth + 1) continue } if ( @@ -101,7 +129,6 @@ async function walkRemoteSessionFiles( files.push(fullPath) } } - return files } function recordRemoteDirectoryIssue( @@ -111,7 +138,7 @@ function recordRemoteDirectoryIssue( path: string, err: unknown ): void { - if (!isMissingRemoteSessionPathError(err)) { + if (!isMissingRemoteSessionPathError(err) && issues.length < REMOTE_DISCOVERY_ISSUE_MAX) { issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) }) } } diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 77806f38be6..4e785a7752e 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -4,6 +4,7 @@ import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { FileWithMtime } from './session-scanner-types' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' +import type { AiVaultSessionDiscoveryBudget } from './session-discovery-budget' export type RemoteScannerContext = { provider: IFilesystemProvider @@ -11,6 +12,7 @@ export type RemoteScannerContext = { hostPlatform: RemoteHostPlatform titleCaches: Map>> antigravityWorkspaceResolver: AntigravityWorkspaceResolver + discoveryBudget: AiVaultSessionDiscoveryBudget } export type RemoteParserOptions = { diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 4ba3bd4d647..49a6ff78907 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -3,6 +3,7 @@ import type { DirEntry } from '../../shared/types' import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { AI_VAULT_SESSION_ID_MAX_UTF8_BYTES } from './session-list-retention' class MemoryRemoteProvider implements IFilesystemProvider { private readonly files = new Map() @@ -668,6 +669,35 @@ describe('scanRemoteAiVaultSessions', () => { expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session']) }) + it('omits remote resume-critical overflow with a bounded issue', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/sessions/oversized.jsonl', + codexTranscript({ + sessionId: 'x'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES + 1), + title: 'Oversized remote identity', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T04:00:00.000Z' + }), + 40 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ + path: '/home/ada/.codex/sessions/oversized.jsonl', + message: expect.stringContaining('session id exceeds the 65536 byte limit') + }) + ]) + }) + it('keeps scoped remote sessions even when they are older than the recency cap', async () => { const provider = new MemoryRemoteProvider() provider.addFile( @@ -706,6 +736,62 @@ describe('scanRemoteAiVaultSessions', () => { 'scoped-session' ]) }) + + it('accepts the aggregate discovery cap and reports cross-source overflow', async () => { + const exactProvider = new MemoryRemoteProvider() + exactProvider.addFile( + '/home/ada/.codex/sessions/exact.jsonl', + codexTranscript({ + sessionId: 'exact-session', + title: 'Exact capacity', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T06:00:00.000Z' + }), + 60 + ) + const exact = await scanRemoteAiVaultSessions({ + provider: exactProvider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + discoveryLimits: { maxEntries: 1 } + }) + + expect(exact.sessions.map((session) => session.sessionId)).toEqual(['exact-session']) + expect(exact.issues).toEqual([]) + + const overflowProvider = new MemoryRemoteProvider() + for (const [path, sessionId] of [ + ['/home/ada/.codex/sessions/default.jsonl', 'default-session'], + [ + '/home/ada/.local/share/orca/codex-runtime-home/home/sessions/managed.jsonl', + 'managed-session' + ] + ] as const) { + overflowProvider.addFile( + path, + codexTranscript({ + sessionId, + title: sessionId, + cwd: '/home/ada/repo', + timestamp: '2026-07-04T06:00:00.000Z' + }), + 60 + ) + } + const overflow = await scanRemoteAiVaultSessions({ + provider: overflowProvider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + discoveryLimits: { maxEntries: 1 } + }) + + expect(overflow.sessions).toHaveLength(1) + expect(overflow.issues).toEqual([ + expect.objectContaining({ message: expect.stringContaining('safety limit') }) + ]) + }) }) function codexTranscript(args: { diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 0ad894214e1..eb6afe3e417 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -18,6 +18,15 @@ import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-sess import { sessionSortTime } from './session-scanner-accumulator' import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { errorMessage } from './session-scanner-values' +import { + AiVaultSessionDiscoveryBudget, + type AiVaultSessionDiscoveryLimits +} from './session-discovery-budget' +import { + boundAiVaultListResult, + retainAiVaultSession, + retainAiVaultSessionsWithinAggregate +} from './session-list-retention' const DEFAULT_REMOTE_SCAN_LIMIT = 1000 const REMOTE_SCAN_CONCURRENCY = 8 @@ -30,6 +39,7 @@ export async function scanRemoteAiVaultSessions(args: { hostPlatform: RemoteHostPlatform limit?: number scopePaths?: readonly string[] + discoveryLimits?: Partial }): Promise { const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT const issues: AiVaultScanIssue[] = [] @@ -38,6 +48,7 @@ export async function scanRemoteAiVaultSessions(args: { executionHostId: args.executionHostId, hostPlatform: args.hostPlatform, titleCaches: new Map(), + discoveryBudget: new AiVaultSessionDiscoveryBudget(args.discoveryLimits), antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => { try { const read = await args.provider.readFile(historyPath) @@ -85,11 +96,11 @@ export async function scanRemoteAiVaultSessions(args: { ...extraScopeSessions ]) - return { + return boundAiVaultListResult({ sessions: mergeRemoteSessions(cappedSessions, scopeSessions), issues, scannedAt: new Date().toISOString() - } + }) } async function parseRemoteSessionCandidates(args: { @@ -116,7 +127,12 @@ async function parseRemoteSessionCandidates(args: { ) sessions.push(...results.filter(isAiVaultSession)) const uniqueSessions = dedupeCodexSessionsBySessionId(sessions) - sessions.splice(0, sessions.length, ...uniqueSessions) + const retained = retainAiVaultSessionsWithinAggregate(uniqueSessions) + sessions.splice(0, sessions.length, ...retained.sessions) + if (retained.omitted > 0) { + addRemoteSessionCapacityIssue(args.issues, args.context.executionHostId, retained.omitted) + break + } index += batch.length } @@ -150,6 +166,12 @@ async function scanRemoteInScopeSessions(args: { isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths) ) ) + const retained = retainAiVaultSessionsWithinAggregate(sessions) + sessions.splice(0, sessions.length, ...retained.sessions) + if (retained.omitted > 0) { + addRemoteSessionCapacityIssue(args.issues, args.context.executionHostId, retained.omitted) + break + } } return sessions @@ -170,10 +192,9 @@ async function parseRemoteSessionCandidate( // transcript count (row badge; recoverable signal at zero turns). The // walk listing supplies it — the parser can't readdir a remote disk. const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0 - if (session && subagentTranscriptCount > 0) { - return { ...session, subagentTranscriptCount } - } - return session + const enriched = + session && subagentTranscriptCount > 0 ? { ...session, subagentTranscriptCount } : session + return enriched ? retainAiVaultSession(enriched) : null } catch (err) { issues.push({ executionHostId: context.executionHostId, @@ -233,6 +254,19 @@ function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSes return Boolean(session) } +function addRemoteSessionCapacityIssue( + issues: AiVaultScanIssue[], + executionHostId: ExecutionHostId, + omitted: number +): void { + issues.push({ + executionHostId, + agent: 'codex', + path: 'AI Vault session list', + message: `AI Vault stopped after omitting ${omitted} sessions at its memory limit.` + }) +} + async function mapRemoteScanConcurrently( items: readonly T[], mapper: (item: T) => Promise diff --git a/src/main/ai-vault/session-discovery-budget.test.ts b/src/main/ai-vault/session-discovery-budget.test.ts new file mode 100644 index 00000000000..8f355588f6c --- /dev/null +++ b/src/main/ai-vault/session-discovery-budget.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { + AiVaultSessionDiscoveryBudget, + AiVaultSessionDiscoveryCapacityError +} from './session-discovery-budget' + +describe('AiVaultSessionDiscoveryBudget', () => { + it('accepts the exact path-memory capacity and rejects the next entry', () => { + const budget = new AiVaultSessionDiscoveryBudget({ maxPathBytes: 130 }) + + expect(() => budget.visitEntry('a')).not.toThrow() + expect(() => budget.visitEntry('b')).toThrow(AiVaultSessionDiscoveryCapacityError) + }) +}) diff --git a/src/main/ai-vault/session-discovery-budget.ts b/src/main/ai-vault/session-discovery-budget.ts new file mode 100644 index 00000000000..ed92b15afc2 --- /dev/null +++ b/src/main/ai-vault/session-discovery-budget.ts @@ -0,0 +1,56 @@ +export const AI_VAULT_DISCOVERY_MAX_ENTRIES = 100_000 +export const AI_VAULT_DISCOVERY_MAX_DEPTH = 64 +export const AI_VAULT_DISCOVERY_MAX_PATH_BYTES = 32 * 1024 * 1024 + +const DISCOVERY_PATH_OVERHEAD_BYTES = 128 + +export type AiVaultSessionDiscoveryLimits = { + maxEntries: number + maxDepth: number + maxPathBytes: number +} + +export class AiVaultSessionDiscoveryCapacityError extends Error { + constructor() { + super( + 'AI Vault session discovery stopped at its safety limit (100,000 entries, 64 folder levels, or 32 MiB of path data).' + ) + this.name = 'AiVaultSessionDiscoveryCapacityError' + } +} + +export class AiVaultSessionDiscoveryBudget { + private entries = 0 + private pathBytes = 0 + readonly limits: AiVaultSessionDiscoveryLimits + + constructor(requested?: Partial) { + this.limits = { + maxEntries: clampLimit(requested?.maxEntries, AI_VAULT_DISCOVERY_MAX_ENTRIES), + maxDepth: clampLimit(requested?.maxDepth, AI_VAULT_DISCOVERY_MAX_DEPTH), + maxPathBytes: clampLimit(requested?.maxPathBytes, AI_VAULT_DISCOVERY_MAX_PATH_BYTES) + } + } + + enterDirectory(depth: number): void { + if (depth > this.limits.maxDepth) { + throw new AiVaultSessionDiscoveryCapacityError() + } + } + + visitEntry(path: string): void { + const nextPathBytes = this.pathBytes + path.length * 2 + DISCOVERY_PATH_OVERHEAD_BYTES + if (this.entries >= this.limits.maxEntries || nextPathBytes > this.limits.maxPathBytes) { + throw new AiVaultSessionDiscoveryCapacityError() + } + this.entries += 1 + this.pathBytes = nextPathBytes + } +} + +function clampLimit(value: number | undefined, maximum: number): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + return maximum + } + return Math.min(value, maximum) +} diff --git a/src/main/ai-vault/session-jsonl-line-reader.test.ts b/src/main/ai-vault/session-jsonl-line-reader.test.ts new file mode 100644 index 00000000000..c78449d48ab --- /dev/null +++ b/src/main/ai-vault/session-jsonl-line-reader.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { consumeAiVaultJsonlLines, iterateAiVaultJsonlLines } from './session-jsonl-line-reader' + +const tempDirs: string[] = [] + +async function tempFile(content: string): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-vault-jsonl-')) + tempDirs.push(directory) + const path = join(directory, 'session.jsonl') + await writeFile(path, content) + return path +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true }))) +}) + +describe('AI Vault bounded JSONL reader', () => { + it('preserves complete UTF-8 and CRLF records plus a trailing partial line', async () => { + const path = await tempFile('first🐋\r\nsecond\npartial') + const lines: string[] = [] + + const result = await consumeAiVaultJsonlLines({ + path, + onLine: (line) => lines.push(line) + }) + + expect(lines).toEqual(['first🐋', 'second']) + expect(result.trailingPartialLine).toBe('partial') + expect(result.consumedThrough).toBe(Buffer.byteLength('first🐋\r\nsecond\n')) + expect(result.bytesRead).toBe(Buffer.byteLength('first🐋\r\nsecond\npartial')) + }) + + it('drops only an oversized record and resumes at the next newline', async () => { + const path = await tempFile(`keep\n${'x'.repeat(40)}\nafter\n`) + const lines: string[] = [] + + const result = await consumeAiVaultJsonlLines({ + path, + maxRecordBytes: 8, + onLine: (line) => lines.push(line) + }) + + expect(lines).toEqual(['keep', 'after']) + expect(result.oversizedRecords).toBe(1) + expect(result.consumedThrough).toBe(Buffer.byteLength(`keep\n${'x'.repeat(40)}\nafter\n`)) + }) + + it('never materializes an oversized unterminated tail', async () => { + const path = await tempFile(`keep\n${'x'.repeat(40)}`) + const iterator = iterateAiVaultJsonlLines(path, { maxRecordBytes: 8 }) + const first = await iterator.next() + const end = await iterator.next() + + expect(first).toMatchObject({ done: false, value: 'keep' }) + expect(end.done).toBe(true) + expect(end.value).toMatchObject({ + trailingPartialLine: null, + trailingPartialOversized: true + }) + }) + + it('yields an accepted unterminated tail for one-shot parsers', async () => { + const path = await tempFile('complete\npartial') + const lines: string[] = [] + + for await (const line of iterateAiVaultJsonlLines(path)) { + lines.push(line) + } + + expect(lines).toEqual(['complete', 'partial']) + }) +}) diff --git a/src/main/ai-vault/session-jsonl-line-reader.ts b/src/main/ai-vault/session-jsonl-line-reader.ts new file mode 100644 index 00000000000..bfabffd9588 --- /dev/null +++ b/src/main/ai-vault/session-jsonl-line-reader.ts @@ -0,0 +1,123 @@ +import { createReadStream } from 'node:fs' + +const NEWLINE_BYTE = 0x0a +const CARRIAGE_RETURN_BYTE = 0x0d + +export const AI_VAULT_JSONL_MAX_RECORD_BYTES = 8 * 1024 * 1024 + +export type AiVaultJsonlReadResult = { + consumedThrough: number + trailingPartialLine: string | null + trailingPartialOversized: boolean + oversizedRecords: number + bytesRead: number +} + +function decodeLine(parts: Buffer[], byteLength: number): string { + const bytes = parts.length === 1 ? parts[0] : Buffer.concat(parts, byteLength) + const end = + byteLength > 0 && bytes[byteLength - 1] === CARRIAGE_RETURN_BYTE ? byteLength - 1 : byteLength + return bytes.toString('utf8', 0, end) +} + +export async function* iterateAiVaultJsonlLines( + path: string, + options: { + start?: number + maxRecordBytes?: number + yieldTrailingPartial?: boolean + } = {} +): AsyncGenerator { + const start = options.start ?? 0 + const maxRecordBytes = options.maxRecordBytes ?? AI_VAULT_JSONL_MAX_RECORD_BYTES + if (!Number.isSafeInteger(start) || start < 0) { + throw new RangeError('JSONL start offset must be a non-negative safe integer') + } + if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes < 0) { + throw new RangeError('JSONL record limit must be a non-negative safe integer') + } + + let bytesRead = 0 + let consumedThrough = start + let lineParts: Buffer[] = [] + let lineBytes = 0 + let discardingLine = false + let oversizedRecords = 0 + const stream = createReadStream(path, { start }) + + for await (const rawChunk of stream as AsyncIterable) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) + const chunkOffset = start + bytesRead + bytesRead += chunk.length + let cursor = 0 + while (cursor < chunk.length) { + const newline = chunk.indexOf(NEWLINE_BYTE, cursor) + const segmentEnd = newline === -1 ? chunk.length : newline + const segmentLength = segmentEnd - cursor + + if (!discardingLine && lineBytes + segmentLength > maxRecordBytes) { + lineParts = [] + lineBytes = 0 + discardingLine = true + } + if (!discardingLine && segmentLength > 0) { + lineParts.push(chunk.subarray(cursor, segmentEnd)) + lineBytes += segmentLength + } + if (newline === -1) { + break + } + + consumedThrough = chunkOffset + newline + 1 + if (discardingLine) { + oversizedRecords += 1 + } else { + yield decodeLine(lineParts, lineBytes) + } + lineParts = [] + lineBytes = 0 + discardingLine = false + cursor = newline + 1 + } + } + + const trailingPartialLine = + !discardingLine && lineBytes > 0 ? decodeLine(lineParts, lineBytes) : null + if (options.yieldTrailingPartial !== false && trailingPartialLine !== null) { + yield trailingPartialLine + } + return { + consumedThrough, + trailingPartialLine, + trailingPartialOversized: discardingLine, + oversizedRecords, + bytesRead + } +} + +export async function consumeAiVaultJsonlLines(args: { + path: string + start?: number + maxRecordBytes?: number + onLine: (line: string) => void +}): Promise { + const iterator = iterateAiVaultJsonlLines(args.path, { + start: args.start, + maxRecordBytes: args.maxRecordBytes, + yieldTrailingPartial: false + }) + try { + while (true) { + const next = await iterator.next() + if (next.done) { + return next.value + } + args.onLine(next.value) + } + } finally { + // Why: a manual next()-drive loop abandons the generator if onLine throws, leaking the + // createReadStream fd; returning the iterator runs its for-await teardown to destroy the stream. + // Cast to AsyncIterator so return() is callable without the generator's required TReturn arg. + await (iterator as AsyncIterator).return?.() + } +} diff --git a/src/main/ai-vault/session-list-results.test.ts b/src/main/ai-vault/session-list-results.test.ts new file mode 100644 index 00000000000..d1cbe002046 --- /dev/null +++ b/src/main/ai-vault/session-list-results.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import { mergeAiVaultListResults } from './session-list-results' + +function session(id: string, modifiedAt: string): AiVaultSession { + return { + id, + executionHostId: 'local', + agent: 'claude', + sessionId: id, + title: id, + cwd: '/repo', + branch: 'main', + model: null, + filePath: `/${id}`, + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt, + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: `claude --resume ${id}`, + subagent: null + } +} + +function result(sessions: AiVaultSession[]): AiVaultListResult { + return { sessions, issues: [], scannedAt: '2026-01-01T00:00:00.000Z' } +} + +describe('AI Vault list result merging', () => { + it('applies the result limit after later hosts replace duplicate sessions', () => { + const newest = session('duplicate', '2026-04-03T00:00:00.000Z') + const fallback = session('fallback', '2026-04-02T00:00:00.000Z') + const replacement = session('duplicate', '2026-04-01T00:00:00.000Z') + const otherHost = session('other-host', '2026-04-01T12:00:00.000Z') + + const merged = mergeAiVaultListResults( + [result([newest, fallback]), result([replacement, otherHost])], + 1 + ) + + expect(merged.sessions).toEqual([fallback]) + }) +}) diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts index 06f8df9c39a..fd6466d40ef 100644 --- a/src/main/ai-vault/session-list-results.ts +++ b/src/main/ai-vault/session-list-results.ts @@ -1,17 +1,14 @@ -import type { - AiVaultListResult, - AiVaultScanIssue, - AiVaultSession -} from '../../shared/ai-vault-types' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { sessionSortTime } from './session-scanner-accumulator' +import { boundAiVaultListResult } from './session-list-retention' export function aiVaultScanIssueResult(args: { executionHostId?: ExecutionHostId path: string message: string }): AiVaultListResult { - return { + return boundAiVaultListResult({ sessions: [], issues: [ { @@ -22,7 +19,7 @@ export function aiVaultScanIssueResult(args: { } ], scannedAt: new Date().toISOString() - } + }) } // Why: the serving-side scan is host-local and cached once for every caller @@ -33,8 +30,9 @@ export function restampAiVaultListResult( result: AiVaultListResult, executionHostId: ExecutionHostId ): AiVaultListResult { - return { - sessions: result.sessions.map((session) => + const retained = boundAiVaultListResult(result) + return boundAiVaultListResult({ + sessions: retained.sessions.map((session) => session.executionHostId === executionHostId ? session : { @@ -43,9 +41,9 @@ export function restampAiVaultListResult( id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` } ), - issues: result.issues.map((issue) => ({ ...issue, executionHostId })), - scannedAt: result.scannedAt - } + issues: retained.issues.map((issue) => ({ ...issue, executionHostId })), + scannedAt: retained.scannedAt + }) } export function mergeAiVaultListResults( @@ -53,19 +51,22 @@ export function mergeAiVaultListResults( rawLimit: number | undefined ): AiVaultListResult { const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 - const byId = new Map() - const issues: AiVaultScanIssue[] = [] - for (const result of results) { - for (const session of result.sessions) { + let merged: AiVaultListResult = { sessions: [], issues: [], scannedAt: new Date().toISOString() } + for (let index = 0; index < results.length; index += 1) { + const rawResult = results[index] + const result = boundAiVaultListResult(rawResult) + const byId = new Map() + for (const session of [...merged.sessions, ...result.sessions]) { byId.set(session.id, session) } - issues.push(...result.issues) - } - return { - sessions: [...byId.values()] - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit), - issues, - scannedAt: new Date().toISOString() + const sessions = [...byId.values()].sort( + (left, right) => sessionSortTime(right) - sessionSortTime(left) + ) + merged = boundAiVaultListResult({ + sessions: index === results.length - 1 ? sessions.slice(0, limit) : sessions, + issues: [...merged.issues, ...result.issues], + scannedAt: new Date().toISOString() + }) } + return merged } diff --git a/src/main/ai-vault/session-list-retention.test.ts b/src/main/ai-vault/session-list-retention.test.ts new file mode 100644 index 00000000000..d15d7f96d4c --- /dev/null +++ b/src/main/ai-vault/session-list-retention.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' +import { + AI_VAULT_SCAN_ISSUE_MAX_ENTRIES, + AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES, + AI_VAULT_SESSION_ID_MAX_UTF8_BYTES, + AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES, + AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES, + AiVaultSessionCapacityError, + aiVaultSessionListCacheKey, + boundAiVaultListResult, + retainAiVaultSession, + retainAiVaultSessionsWithinAggregate +} from './session-list-retention' + +function session(overrides: Partial = {}): AiVaultSession { + return { + id: 'local:claude:session:/transcript', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session', + title: 'Title', + cwd: '/repo', + branch: 'main', + model: 'claude-test', + filePath: '/transcript', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-01-01T00:00:00.000Z', + messageCount: 1, + totalTokens: 1, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'claude --resume session', + subagent: null, + ...overrides + } +} + +function result(sessions: AiVaultSession[], issues: AiVaultScanIssue[] = []): AiVaultListResult { + return { sessions, issues, scannedAt: '2026-01-01T00:00:00.000Z' } +} + +describe('AI Vault session list retention', () => { + it('returns exact under-limit sessions and results by identity', () => { + const normalSession = session() + const normalResult = result( + [normalSession], + [{ agent: 'claude', path: '/transcript', message: 'recoverable warning' }] + ) + + expect(retainAiVaultSession(normalSession)).toBe(normalSession) + expect(boundAiVaultListResult(normalResult)).toBe(normalResult) + }) + + it('accepts exact UTF-8 identity/path limits and rejects one byte over', () => { + const exactId = 'é'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES / 2) + const exactPath = 'é'.repeat(AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES / 2) + expect(retainAiVaultSession(session({ sessionId: exactId, filePath: exactPath }))).toEqual( + expect.objectContaining({ sessionId: exactId, filePath: exactPath }) + ) + + expect(() => retainAiVaultSession(session({ sessionId: `${exactId}x` }))).toThrow( + AiVaultSessionCapacityError + ) + expect(() => retainAiVaultSession(session({ filePath: `${exactPath}x` }))).toThrow( + AiVaultSessionCapacityError + ) + }) + + it('UTF-8 truncates display-only metadata without splitting a code point', () => { + const exact = 'é'.repeat(AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES / 2) + expect(retainAiVaultSession(session({ model: exact })).model).toBe(exact) + + const retained = retainAiVaultSession(session({ model: `${exact}x` })) + expect(retained.model).toMatch(/\.\.\.$/) + expect(Buffer.byteLength(retained.model ?? '', 'utf8')).toBeLessThanOrEqual( + AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES + ) + expect(retained.model).not.toContain('\ufffd') + }) + + it('uses the exact list boundary and keeps the newest session prefix', () => { + const sessions = [session({ id: 'newest' }), session({ id: 'older' })] + const exactArrayBytes = Buffer.byteLength(JSON.stringify(sessions), 'utf8') + expect(retainAiVaultSessionsWithinAggregate(sessions, exactArrayBytes)).toEqual({ + sessions, + omitted: 0 + }) + expect(retainAiVaultSessionsWithinAggregate(sessions, exactArrayBytes - 1)).toEqual({ + sessions: [sessions[0]], + omitted: 1 + }) + + const exactResult = result(sessions) + const exactResultBytes = Buffer.byteLength(JSON.stringify(exactResult), 'utf8') + expect(boundAiVaultListResult(exactResult, exactResultBytes)).toBe(exactResult) + const overflow = boundAiVaultListResult(exactResult, exactResultBytes - 1) + expect(Buffer.byteLength(JSON.stringify(overflow), 'utf8')).toBeLessThanOrEqual( + exactResultBytes - 1 + ) + expect(overflow.issues.at(-1)?.message).toContain('AI Vault omitted') + }) + + it('turns critical overflow into one bounded issue and caps issue count', () => { + const oversized = session({ + sessionId: 'x'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES + 1) + }) + const issues = Array.from({ length: AI_VAULT_SCAN_ISSUE_MAX_ENTRIES + 1 }, (_, index) => ({ + agent: 'claude' as const, + path: `/warning/${index}`, + message: 'warning' + })) + + const bounded = boundAiVaultListResult(result([oversized], issues)) + expect(bounded.sessions).toEqual([]) + expect(bounded.issues).toHaveLength(AI_VAULT_SCAN_ISSUE_MAX_ENTRIES) + expect(bounded.issues.at(-1)?.message).toContain('1 sessions and 2 scan issues') + }) + + it('admits an exact-limit cache key and bypasses caching one byte over', () => { + const overhead = Buffer.byteLength(JSON.stringify({ scopePaths: [''] }), 'utf8') + const exact = { + scopePaths: ['x'.repeat(AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES - overhead)] + } + expect(Buffer.byteLength(JSON.stringify(exact), 'utf8')).toBe( + AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES + ) + expect(aiVaultSessionListCacheKey(exact)).toBe(JSON.stringify(exact)) + expect(aiVaultSessionListCacheKey({ scopePaths: [`${exact.scopePaths[0]}x`] })).toBeNull() + }) +}) diff --git a/src/main/ai-vault/session-list-retention.ts b/src/main/ai-vault/session-list-retention.ts new file mode 100644 index 00000000000..4038f911f3c --- /dev/null +++ b/src/main/ai-vault/session-list-retention.ts @@ -0,0 +1,321 @@ +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' +import { stringifyJsonWithinByteLimit } from '../../shared/memory-safety/node-bounded-json-stringify' + +export const AI_VAULT_SESSION_ID_MAX_UTF8_BYTES = 64 * 1024 +export const AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES = 128 * 1024 +export const AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES = 4 * 1024 +export const AI_VAULT_SESSION_MAX_JSON_BYTES = 1024 * 1024 +export const AI_VAULT_SESSION_LIST_MAX_JSON_BYTES = 32 * 1024 * 1024 +export const AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES = 256 * 1024 +export const AI_VAULT_SCAN_ISSUE_MAX_ENTRIES = 1024 + +const AI_VAULT_SESSION_DERIVED_FIELD_MAX_UTF8_BYTES = 512 * 1024 +const AI_VAULT_SCAN_ISSUE_FIELD_MAX_UTF8_BYTES = 16 * 1024 +const AI_VAULT_SCAN_ISSUE_MAX_JSON_BYTES = 64 * 1024 +const AI_VAULT_SESSION_PREVIEW_MAX_MESSAGES = 5 +const TRUNCATION_MARKER = '...' + +export class AiVaultSessionCapacityError extends Error { + constructor(field: string, maxBytes: number) { + super(`AI Vault omitted a session because its ${field} exceeds the ${maxBytes} byte limit.`) + this.name = 'AiVaultSessionCapacityError' + } +} + +export function retainAiVaultSession(session: AiVaultSession): AiVaultSession { + requireField(session.executionHostId, 64 * 1024, 'execution host id') + requireField(session.sessionId, AI_VAULT_SESSION_ID_MAX_UTF8_BYTES, 'session id') + requireField(session.cwd, AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES, 'working directory') + requireField(session.filePath, AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES, 'transcript path') + requireField(session.codexHome, AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES, 'Codex home') + requireField(session.id, AI_VAULT_SESSION_DERIVED_FIELD_MAX_UTF8_BYTES, 'derived id') + requireField( + session.resumeCommand, + AI_VAULT_SESSION_DERIVED_FIELD_MAX_UTF8_BYTES, + 'resume command' + ) + if (session.subagent) { + requireField( + session.subagent.parentSessionId, + AI_VAULT_SESSION_ID_MAX_UTF8_BYTES, + 'parent session id' + ) + } + + const title = truncateUtf8(session.title, AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES) + const branch = truncateNullableUtf8(session.branch, AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES) + const model = truncateNullableUtf8(session.model, AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES) + const lastUserPrompt = truncateNullableUtf8( + session.lastUserPrompt, + AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES + ) + const previewSource = + session.previewMessages.length > AI_VAULT_SESSION_PREVIEW_MAX_MESSAGES + ? session.previewMessages.slice(-AI_VAULT_SESSION_PREVIEW_MAX_MESSAGES) + : session.previewMessages + const previewMessages = previewSource.map((message) => { + const text = truncateUtf8(message.text, AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES) + return text === message.text ? message : { ...message, text } + }) + const agentType = session.subagent + ? truncateNullableUtf8( + session.subagent.agentType, + AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES + ) + : undefined + const changed = + title !== session.title || + branch !== session.branch || + model !== session.model || + lastUserPrompt !== session.lastUserPrompt || + previewSource !== session.previewMessages || + previewMessages.some((message, index) => message !== session.previewMessages[index]) || + agentType !== session.subagent?.agentType + const retained = changed + ? { + ...session, + title, + branch, + model, + ...(session.lastUserPrompt === undefined ? {} : { lastUserPrompt }), + previewMessages, + ...(session.subagent + ? { subagent: { ...session.subagent, agentType: agentType ?? null } } + : {}) + } + : session + try { + stringifyJsonWithinByteLimit(retained, AI_VAULT_SESSION_MAX_JSON_BYTES) + } catch { + throw new AiVaultSessionCapacityError('serialized metadata', AI_VAULT_SESSION_MAX_JSON_BYTES) + } + return retained +} + +export function retainAiVaultSessionsWithinAggregate( + sessions: readonly AiVaultSession[], + maxBytes: number = AI_VAULT_SESSION_LIST_MAX_JSON_BYTES +): { + sessions: AiVaultSession[] + omitted: number +} { + const limit = boundedListLimit(maxBytes) + const retained: AiVaultSession[] = [] + let retainedBytes = 2 + let omitted = 0 + for (let index = 0; index < sessions.length; index += 1) { + try { + const session = retainAiVaultSession(sessions[index]) + const bytes = stringifyJsonWithinByteLimit( + session, + AI_VAULT_SESSION_MAX_JSON_BYTES + ).byteLength + const nextBytes = retainedBytes + bytes + (retained.length > 0 ? 1 : 0) + if (nextBytes > limit) { + omitted += sessions.length - index + break + } + retained.push(session) + retainedBytes = nextBytes + } catch { + omitted++ + } + } + return { sessions: retained, omitted } +} + +export function boundAiVaultListResult( + result: AiVaultListResult, + maxBytes: number = AI_VAULT_SESSION_LIST_MAX_JSON_BYTES +): AiVaultListResult { + const limit = boundedListLimit(maxBytes) + const scannedAt = truncateUtf8(result.scannedAt, 256) + const baseBytes = stringifyJsonWithinByteLimit( + { sessions: [], issues: [], scannedAt }, + limit + ).byteLength + const sessions: AiVaultSession[] = [] + const sessionBytes: number[] = [] + const issues: AiVaultScanIssue[] = [] + const issueBytes: number[] = [] + let retainedBytes = baseBytes + let omittedSessions = 0 + let omittedIssues = 0 + let firstOmittedAgent: AiVaultScanIssue['agent'] | null = null + let changed = scannedAt !== result.scannedAt + + for (let index = 0; index < result.sessions.length; index += 1) { + try { + const session = retainAiVaultSession(result.sessions[index]) + const bytes = stringifyJsonWithinByteLimit( + session, + AI_VAULT_SESSION_MAX_JSON_BYTES + ).byteLength + const nextBytes = retainedBytes + bytes + (sessions.length > 0 ? 1 : 0) + if (nextBytes > limit) { + omittedSessions += result.sessions.length - index + firstOmittedAgent ??= session.agent + changed = true + break + } + sessions.push(session) + sessionBytes.push(bytes) + retainedBytes = nextBytes + changed ||= session !== result.sessions[index] + } catch { + omittedSessions++ + firstOmittedAgent ??= result.sessions[index].agent + changed = true + } + } + + for (let index = 0; index < result.issues.length; index += 1) { + if (issues.length >= AI_VAULT_SCAN_ISSUE_MAX_ENTRIES) { + omittedIssues += result.issues.length - index + changed = true + break + } + const issue = retainScanIssue(result.issues[index]) + const bytes = stringifyJsonWithinByteLimit(issue, AI_VAULT_SCAN_ISSUE_MAX_JSON_BYTES).byteLength + const nextBytes = retainedBytes + bytes + (issues.length > 0 ? 1 : 0) + if (nextBytes > limit) { + omittedIssues += result.issues.length - index + changed = true + break + } + issues.push(issue) + issueBytes.push(bytes) + retainedBytes = nextBytes + changed ||= issue !== result.issues[index] + } + + if (omittedSessions > 0 || omittedIssues > 0) { + let summary = overflowSummary(firstOmittedAgent, omittedSessions, omittedIssues) + let summaryBytes = issueJsonBytes(summary) + while ( + issues.length >= AI_VAULT_SCAN_ISSUE_MAX_ENTRIES || + retainedBytes + summaryBytes + (issues.length > 0 ? 1 : 0) > limit + ) { + if (issues.length > 0) { + const removedBytes = issueBytes.pop() ?? 0 + issues.pop() + retainedBytes -= removedBytes + (issues.length > 0 ? 1 : 0) + omittedIssues++ + } else if (sessions.length > 0) { + const removedBytes = sessionBytes.pop() ?? 0 + sessions.pop() + retainedBytes -= removedBytes + (sessions.length > 0 ? 1 : 0) + omittedSessions++ + } else { + break + } + summary = overflowSummary(firstOmittedAgent, omittedSessions, omittedIssues) + summaryBytes = issueJsonBytes(summary) + } + issues.push(summary) + } + + return changed ? { sessions, issues, scannedAt } : result +} + +export function aiVaultSessionListCacheKey(value: unknown): string | null { + try { + return stringifyJsonWithinByteLimit(value, AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES) + .serialized + } catch { + return null + } +} + +function retainScanIssue(issue: AiVaultScanIssue): AiVaultScanIssue { + const executionHostId = + issue.executionHostId && + !exceedsUtf8Bytes(issue.executionHostId, AI_VAULT_SCAN_ISSUE_FIELD_MAX_UTF8_BYTES) + ? issue.executionHostId + : undefined + const path = truncateUtf8(issue.path, AI_VAULT_SCAN_ISSUE_FIELD_MAX_UTF8_BYTES) + const message = truncateUtf8(issue.message, AI_VAULT_SCAN_ISSUE_FIELD_MAX_UTF8_BYTES) + return executionHostId === issue.executionHostId && + path === issue.path && + message === issue.message + ? issue + : { + ...issue, + executionHostId, + path, + message + } +} + +function requireField(value: string | null, maxBytes: number, field: string): void { + if (value !== null && exceedsUtf8Bytes(value, maxBytes)) { + throw new AiVaultSessionCapacityError(field, maxBytes) + } +} + +function truncateNullableUtf8(value: string | null, maxBytes: number): string | null +function truncateNullableUtf8( + value: string | null | undefined, + maxBytes: number +): string | null | undefined +function truncateNullableUtf8( + value: string | null | undefined, + maxBytes: number +): string | null | undefined { + return value == null ? value : truncateUtf8(value, maxBytes) +} + +function truncateUtf8(value: string, maxBytes: number): string { + if (!exceedsUtf8Bytes(value, maxBytes)) { + return value + } + const prefixBytes = Math.max(0, maxBytes - TRUNCATION_MARKER.length) + const end = utf8PrefixEnd(value, prefixBytes) + return `${value.slice(0, end)}${TRUNCATION_MARKER}` +} + +function exceedsUtf8Bytes(value: string, maxBytes: number): boolean { + return utf8PrefixEnd(value, maxBytes) < value.length +} + +function utf8PrefixEnd(value: string, maxBytes: number): number { + let bytes = 0 + let index = 0 + while (index < value.length) { + const code = value.codePointAt(index) ?? 0 + const charBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4 + if (bytes + charBytes > maxBytes) { + break + } + bytes += charBytes + index += code > 0xffff ? 2 : 1 + } + return index +} + +function overflowSummary( + agent: AiVaultScanIssue['agent'] | null, + omittedSessions: number, + omittedIssues: number +): AiVaultScanIssue { + return { + agent: agent ?? 'codex', + path: 'AI Vault session list', + message: `AI Vault omitted ${omittedSessions} sessions and ${omittedIssues} scan issues because the result exceeded its memory limits.` + } +} + +function issueJsonBytes(issue: AiVaultScanIssue): number { + return stringifyJsonWithinByteLimit(issue, AI_VAULT_SCAN_ISSUE_MAX_JSON_BYTES).byteLength +} + +function boundedListLimit(requested: number): number { + if (!Number.isSafeInteger(requested) || requested < 0) { + throw new RangeError('AI Vault session list limit must be a non-negative safe integer') + } + return Math.min(requested, AI_VAULT_SESSION_LIST_MAX_JSON_BYTES) +} diff --git a/src/main/ai-vault/session-parse-cache-payload.test.ts b/src/main/ai-vault/session-parse-cache-payload.test.ts new file mode 100644 index 00000000000..21fe145e1ca --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-payload.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES, + AI_VAULT_PARSE_CACHE_PERSIST_MAX_UTF8_BYTES, + serializeSessionParseCachePayload +} from './session-parse-cache-payload' +import type { PersistedSessionParseCacheEntry } from './session-parse-cache-retention' +import { AI_VAULT_WHOLE_JSON_MAX_BYTES } from './session-whole-json-reader' + +function entry(model: string | null = null): PersistedSessionParseCacheEntry { + return { + mtimeMs: 1, + sizeBytes: 2, + platform: process.platform, + session: session(model) + } +} + +function session(model: string | null): AiVaultSession { + return { + id: 'local:claude:session:/transcript', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session', + title: 'Title', + cwd: '/repo', + branch: 'main', + model, + filePath: '/transcript', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-01-01T00:00:00.000Z', + messageCount: 1, + totalTokens: 1, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'claude --resume session', + subagent: null + } +} + +describe('session parse cache payload', () => { + it('is byte-identical to native JSON when every LRU entry fits', () => { + const entries: [string, PersistedSessionParseCacheEntry][] = [ + ['/old\n✨', entry('first')], + ['/new', entry('second')] + ] + const expected = JSON.stringify({ schemaVersion: 1, appVersion: '1.2.3', entries }) + + expect( + serializeSessionParseCachePayload({ schemaVersion: 1, appVersion: '1.2.3', entries }) + ).toBe(expected) + }) + + it('uses the exact byte boundary and prioritizes newest LRU entries', () => { + const older: [string, PersistedSessionParseCacheEntry] = ['/older', entry('x'.repeat(200))] + const middle: [string, PersistedSessionParseCacheEntry] = ['/middle', entry('middle')] + const newest: [string, PersistedSessionParseCacheEntry] = ['/newest', entry('newest')] + const expected = JSON.stringify({ + schemaVersion: 1, + appVersion: '1.2.3', + entries: [middle, newest] + }) + const exactBytes = Buffer.byteLength(expected, 'utf8') + + const exact = serializeSessionParseCachePayload({ + schemaVersion: 1, + appVersion: '1.2.3', + entries: [older, middle, newest], + maxBytes: exactBytes + }) + expect(exact).toBe(expected) + expect(Buffer.byteLength(exact, 'utf8')).toBe(exactBytes) + + const overflow = JSON.parse( + serializeSessionParseCachePayload({ + schemaVersion: 1, + appVersion: '1.2.3', + entries: [older, middle, newest], + maxBytes: exactBytes - 1 + }) + ) as { entries: [string, PersistedSessionParseCacheEntry][] } + expect(overflow.entries.map(([path]) => path)).toEqual(['/newest']) + }) + + it('caps production output below the 64 MiB reader ceiling', () => { + const payload = serializeSessionParseCachePayload({ + schemaVersion: 1, + appVersion: '1.2.3', + entries: [['/too-large', entry('x'.repeat(1024 * 1024))]], + maxBytes: 512 + }) + + expect(JSON.parse(payload).entries).toEqual([]) + expect(Buffer.byteLength(payload, 'utf8')).toBeLessThanOrEqual(512) + expect(AI_VAULT_PARSE_CACHE_PERSIST_MAX_UTF8_BYTES).toBeLessThan(AI_VAULT_WHOLE_JSON_MAX_BYTES) + }) + + it('admits an exact-limit encoded app version and rejects one byte over', () => { + const exact = 'x'.repeat(AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES - 2) + expect(Buffer.byteLength(JSON.stringify(exact), 'utf8')).toBe( + AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES + ) + expect(() => + serializeSessionParseCachePayload({ schemaVersion: 1, appVersion: exact, entries: [] }) + ).not.toThrow() + + expect(() => + serializeSessionParseCachePayload({ + schemaVersion: 1, + appVersion: `${exact}x`, + entries: [] + }) + ).toThrow(`JSON output exceeds ${AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES} bytes`) + }) +}) diff --git a/src/main/ai-vault/session-parse-cache-payload.ts b/src/main/ai-vault/session-parse-cache-payload.ts new file mode 100644 index 00000000000..a0db76895a7 --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-payload.ts @@ -0,0 +1,66 @@ +import { + JsonStringifyByteLimitError, + stringifyJsonWithinByteLimit +} from '../../shared/memory-safety/node-bounded-json-stringify' +import type { PersistedSessionParseCacheEntry } from './session-parse-cache-retention' + +// Half the loader's 64 MiB ceiling leaves headroom for the UTF-16 string and +// the writeFile encoding buffer to coexist during a save. +export const AI_VAULT_PARSE_CACHE_PERSIST_MAX_UTF8_BYTES = 32 * 1024 * 1024 +export const AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES = 4 * 1024 + +export function serializeSessionParseCachePayload(args: { + schemaVersion: number + appVersion: string + entries: readonly [string, PersistedSessionParseCacheEntry][] + maxBytes?: number +}): string { + if (!Number.isSafeInteger(args.schemaVersion)) { + throw new TypeError('Session parse cache schema version must be a safe integer') + } + const maxBytes = boundedPayloadLimit(args.maxBytes) + const appVersion = stringifyJsonWithinByteLimit( + args.appVersion, + AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES + ).serialized + const prefix = `{"schemaVersion":${args.schemaVersion},"appVersion":${appVersion},"entries":[` + const suffix = ']}' + let retainedBytes = Buffer.byteLength(prefix, 'utf8') + Buffer.byteLength(suffix, 'utf8') + if (retainedBytes > maxBytes) { + throw new JsonStringifyByteLimitError(retainedBytes, maxBytes) + } + + const newestFirst: string[] = [] + for (let index = args.entries.length - 1; index >= 0; index -= 1) { + const separatorBytes = newestFirst.length === 0 ? 0 : 1 + const remaining = maxBytes - retainedBytes - separatorBytes + if (remaining < 0) { + continue + } + try { + const encoded = stringifyJsonWithinByteLimit(args.entries[index], remaining) + newestFirst.push(encoded.serialized) + retainedBytes += separatorBytes + encoded.byteLength + } catch { + // One entry that cannot fit must not prevent smaller older entries from persisting. + } + } + + const fragments = [prefix] + for (let index = newestFirst.length - 1; index >= 0; index -= 1) { + if (index < newestFirst.length - 1) { + fragments.push(',') + } + fragments.push(newestFirst[index]) + } + fragments.push(suffix) + return fragments.join('') +} + +function boundedPayloadLimit(requested: number | undefined): number { + const limit = requested ?? AI_VAULT_PARSE_CACHE_PERSIST_MAX_UTF8_BYTES + if (!Number.isSafeInteger(limit) || limit < 0) { + throw new RangeError('Session parse cache payload limit must be a non-negative safe integer') + } + return Math.min(limit, AI_VAULT_PARSE_CACHE_PERSIST_MAX_UTF8_BYTES) +} diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts index f4a924bd82f..519e024785d 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -3,7 +3,7 @@ // of re-reading the whole transcript corpus (issue #9210: 6.7 GB / 109 s cold // scans). Disabled unless the composition root calls init; every failure mode // degrades to today's cold-scan behavior. -import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { mkdir, opendir, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { seedSessionParseCache, @@ -11,6 +11,9 @@ import { type PersistedSessionParseCacheEntry, type SessionParseStats } from './session-scanner-parse-cache' +import { serializeSessionParseCachePayload } from './session-parse-cache-payload' +import { withAiVaultWholeJsonFile } from './session-whole-json-reader' +import { parseAiVaultJsonText } from './session-scanner-values' // Bump when the persisted entry layout changes; a mismatched file is discarded whole. const SCHEMA_VERSION = 1 @@ -98,11 +101,12 @@ export async function flushSessionParseCachePersistForTests(): Promise { async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise { await sweepOrphanedTempFiles(current.filePath) try { - const raw = await readFile(current.filePath, 'utf-8') - const entries = parsePersistedFile(JSON.parse(raw), current.appVersion) - if (entries) { - seedSessionParseCache(entries) - } + await withAiVaultWholeJsonFile(current.filePath, (content) => { + const entries = parsePersistedFile(parseAiVaultJsonText(content), current.appVersion) + if (entries) { + seedSessionParseCache(entries) + } + }) } catch { // Why: a missing/corrupt/foreign cache file must never fail the scan; // worst case is exactly today's cold scan. @@ -115,12 +119,12 @@ async function loadPersistedEntries(current: SessionParseCachePersistenceOptions async function sweepOrphanedTempFiles(filePath: string): Promise { const directory = dirname(filePath) try { - const names = await readdir(directory) - await Promise.all( - names - .filter((name) => name.startsWith('session-parse-cache-') && name.endsWith('.tmp')) - .map((name) => rm(join(directory, name), { force: true }).catch(() => {})) - ) + const entries = await opendir(directory) + for await (const entry of entries) { + if (entry.name.startsWith('session-parse-cache-') && entry.name.endsWith('.tmp')) { + await rm(join(directory, entry.name), { force: true }).catch(() => {}) + } + } } catch { // Directory missing or unreadable — nothing to sweep. } @@ -190,7 +194,7 @@ async function persistSnapshot(current: SessionParseCachePersistenceOptions): Pr const directory = dirname(current.filePath) const tempPath = join(directory, `session-parse-cache-${process.pid}-${Date.now()}.tmp`) try { - const payload = JSON.stringify({ + const payload = serializeSessionParseCachePayload({ schemaVersion: SCHEMA_VERSION, appVersion: current.appVersion, entries: snapshotSessionParseCacheForPersistence() diff --git a/src/main/ai-vault/session-parse-cache-retention.test.ts b/src/main/ai-vault/session-parse-cache-retention.test.ts new file mode 100644 index 00000000000..980e63e72bc --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-retention.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + AI_VAULT_PARSE_CACHE_KEY_MAX_UTF8_BYTES, + AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES, + inspectSessionParseCacheRetentionForTests, + resetSessionParseCacheRetentionForTests, + seedSessionParseCache, + setSessionParseCacheMaxRetainedBytesForTests, + snapshotSessionParseCacheForPersistence, + storeSessionParseCacheEntry, + type PersistedSessionParseCacheEntry, + type SessionParseCacheEntry +} from './session-parse-cache-retention' +import type { ResumableSessionParseState } from './session-scanner-types' + +beforeEach(() => resetSessionParseCacheRetentionForTests()) +afterEach(() => resetSessionParseCacheRetentionForTests()) + +function persistedEntry(model: string | null = null): PersistedSessionParseCacheEntry { + return { + mtimeMs: 1, + sizeBytes: 2, + platform: process.platform, + session: session(model) + } +} + +function cacheEntry(model: string | null = null): SessionParseCacheEntry { + return { ...persistedEntry(model), resume: null } +} + +function cacheEntryWithResumeBytes(bytes: number): SessionParseCacheEntry { + let state: ResumableSessionParseState + state = { + consumeLine: () => {}, + clone: () => state, + retainedUtf8Bytes: () => bytes, + touchFile: () => {}, + finalize: () => null + } + return { ...persistedEntry(), resume: { state, byteOffset: 0 } } +} + +function session(model: string | null): AiVaultSession { + return { + id: 'local:claude:session:/transcript', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session', + title: 'Title', + cwd: '/repo', + branch: 'main', + model, + filePath: '/transcript', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-01-01T00:00:00.000Z', + messageCount: 1, + totalTokens: 1, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'claude --resume session', + subagent: null + } +} + +function entryWithSerializedBytes(targetBytes: number): PersistedSessionParseCacheEntry { + const entry = persistedEntry('') + const baseBytes = Buffer.byteLength(JSON.stringify(entry), 'utf8') + expect(baseBytes).toBeLessThan(targetBytes) + entry.session!.model = 'x'.repeat(targetBytes - baseBytes) + expect(Buffer.byteLength(JSON.stringify(entry), 'utf8')).toBe(targetBytes) + return entry +} + +function retainedBytes(path: string, entry: PersistedSessionParseCacheEntry): number { + return Buffer.byteLength(path, 'utf8') + Buffer.byteLength(JSON.stringify(entry), 'utf8') +} + +describe('session parse cache retention', () => { + it('admits an exact-limit UTF-8 key and rejects one byte over', () => { + const exact = 'é'.repeat(AI_VAULT_PARSE_CACHE_KEY_MAX_UTF8_BYTES / 2) + const overflow = `${exact}x` + expect(Buffer.byteLength(exact, 'utf8')).toBe(AI_VAULT_PARSE_CACHE_KEY_MAX_UTF8_BYTES) + + seedSessionParseCache([ + [exact, persistedEntry()], + [overflow, persistedEntry()] + ]) + + expect(snapshotSessionParseCacheForPersistence().map(([path]) => path)).toEqual([exact]) + }) + + it('admits an exact-limit serialized value and rejects one byte over', () => { + const exact = entryWithSerializedBytes(AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES) + const overflow = entryWithSerializedBytes(AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES + 1) + + seedSessionParseCache([ + ['/exact', exact], + ['/overflow', overflow] + ]) + + expect(snapshotSessionParseCacheForPersistence()).toEqual([['/exact', exact]]) + }) + + it('includes the non-persisted resume fold in the exact value limit', () => { + const persistedBytes = Buffer.byteLength(JSON.stringify(persistedEntry()), 'utf8') + const exactResumeBytes = AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES - persistedBytes + + storeSessionParseCacheEntry('/exact-resume', cacheEntryWithResumeBytes(exactResumeBytes)) + storeSessionParseCacheEntry('/overflow-resume', cacheEntryWithResumeBytes(exactResumeBytes + 1)) + + expect(inspectSessionParseCacheRetentionForTests().paths).toEqual(['/exact-resume']) + }) + + it('fills the aggregate UTF-8 budget exactly and keeps the newest entries on overflow', () => { + const first: [string, PersistedSessionParseCacheEntry] = ['/a', persistedEntry()] + const second: [string, PersistedSessionParseCacheEntry] = ['/b', persistedEntry()] + const exactBytes = retainedBytes(...first) + retainedBytes(...second) + setSessionParseCacheMaxRetainedBytesForTests(exactBytes) + + seedSessionParseCache([first, second]) + expect(inspectSessionParseCacheRetentionForTests()).toEqual({ + paths: ['/a', '/b'], + retainedUtf8Bytes: exactBytes + }) + + setSessionParseCacheMaxRetainedBytesForTests(exactBytes - 1) + seedSessionParseCache([first, second]) + expect(inspectSessionParseCacheRetentionForTests().paths).toEqual(['/b']) + }) + + it('refreshes LRU recency before aggregate eviction', () => { + const persisted = persistedEntry() + const entry = cacheEntry() + const twoEntryBytes = retainedBytes('/a', persisted) + retainedBytes('/b', persisted) + setSessionParseCacheMaxRetainedBytesForTests(twoEntryBytes) + + storeSessionParseCacheEntry('/a', entry) + storeSessionParseCacheEntry('/b', entry) + storeSessionParseCacheEntry('/a', entry) + storeSessionParseCacheEntry('/c', entry) + + expect(inspectSessionParseCacheRetentionForTests().paths).toEqual(['/a', '/c']) + }) +}) diff --git a/src/main/ai-vault/session-parse-cache-retention.ts b/src/main/ai-vault/session-parse-cache-retention.ts new file mode 100644 index 00000000000..ac8e4263a0a --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-retention.ts @@ -0,0 +1,180 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { stringifyJsonWithinByteLimit } from '../../shared/memory-safety/node-bounded-json-stringify' +import type { ResumableSessionParseState } from './session-scanner-types' + +// Covers the 1,000 recent plus 2,000 in-scope result caps with refresh headroom. +export const AI_VAULT_PARSE_CACHE_MAX_ENTRIES = 4_096 +export const AI_VAULT_PARSE_CACHE_KEY_MAX_UTF8_BYTES = 32 * 1024 +export const AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES = 256 * 1024 +export const AI_VAULT_PARSE_CACHE_MAX_RETAINED_UTF8_BYTES = 32 * 1024 * 1024 + +export type SessionParseCacheEntry = { + mtimeMs: number + sizeBytes: number | null + platform: NodeJS.Platform + session: AiVaultSession | null + resume: { + state: ResumableSessionParseState + // A trailing unterminated record must be reread after its writer completes it. + byteOffset: number + } | null +} + +export type PersistedSessionParseCacheEntry = Omit + +type RetainedEntry = { + entry: SessionParseCacheEntry + retainedUtf8Bytes: number +} + +const cache = new Map() +let retainedUtf8Bytes = 0 +let maxRetainedUtf8Bytes = AI_VAULT_PARSE_CACHE_MAX_RETAINED_UTF8_BYTES + +export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry | undefined { + return cache.get(path)?.entry +} + +export function resetSessionParseCacheRetentionForTests(): void { + cache.clear() + retainedUtf8Bytes = 0 + maxRetainedUtf8Bytes = AI_VAULT_PARSE_CACHE_MAX_RETAINED_UTF8_BYTES +} + +export function setSessionParseCacheMaxRetainedBytesForTests(maxBytes: number): void { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + throw new RangeError('Session parse cache byte limit must be a non-negative safe integer') + } + cache.clear() + retainedUtf8Bytes = 0 + maxRetainedUtf8Bytes = maxBytes +} + +export function inspectSessionParseCacheRetentionForTests(): { + paths: string[] + retainedUtf8Bytes: number +} { + return { paths: [...cache.keys()], retainedUtf8Bytes } +} + +export function snapshotSessionParseCacheForPersistence(): [ + string, + PersistedSessionParseCacheEntry +][] { + return [...cache].map(([path, retained]) => [path, persistedEntry(retained.entry)]) +} + +export function seedSessionParseCache( + entries: Iterable<[string, PersistedSessionParseCacheEntry]> +): void { + const tail = newestEntryTail(entries) + const selected: [string, SessionParseCacheEntry, number][] = [] + const selectedPaths = new Set() + let availableEntries = AI_VAULT_PARSE_CACHE_MAX_ENTRIES - cache.size + let availableBytes = maxRetainedUtf8Bytes - retainedUtf8Bytes + + for (let index = tail.length - 1; index >= 0 && availableEntries > 0; index -= 1) { + const [path, persisted] = tail[index] + // Live entries are fresher than disk, and the newest duplicate seed wins. + if (cache.has(path) || selectedPaths.has(path)) { + continue + } + selectedPaths.add(path) + const entry: SessionParseCacheEntry = { ...persisted, resume: null } + const bytes = retainedEntryUtf8Bytes(path, entry) + if (bytes === null || bytes > availableBytes) { + continue + } + selected.push([path, entry, bytes]) + availableEntries-- + availableBytes -= bytes + } + + for (let index = selected.length - 1; index >= 0; index -= 1) { + const [path, entry, bytes] = selected[index] + cache.set(path, { entry, retainedUtf8Bytes: bytes }) + retainedUtf8Bytes += bytes + } +} + +export function storeSessionParseCacheEntry(path: string, entry: SessionParseCacheEntry): void { + deleteSessionParseCacheEntry(path) + const bytes = retainedEntryUtf8Bytes(path, entry) + if (bytes === null || bytes > maxRetainedUtf8Bytes) { + return + } + + while ( + cache.size >= AI_VAULT_PARSE_CACHE_MAX_ENTRIES || + retainedUtf8Bytes + bytes > maxRetainedUtf8Bytes + ) { + const oldest = cache.keys().next().value + if (oldest === undefined) { + return + } + deleteSessionParseCacheEntry(oldest) + } + + cache.set(path, { entry, retainedUtf8Bytes: bytes }) + retainedUtf8Bytes += bytes +} + +function retainedEntryUtf8Bytes(path: string, entry: SessionParseCacheEntry): number | null { + const keyBytes = Buffer.byteLength(path, 'utf8') + if (keyBytes > AI_VAULT_PARSE_CACHE_KEY_MAX_UTF8_BYTES) { + return null + } + try { + const persistedBytes = stringifyJsonWithinByteLimit( + persistedEntry(entry), + AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES + ).byteLength + const resumeBytes = entry.resume?.state.retainedUtf8Bytes() ?? 0 + const valueBytes = persistedBytes + resumeBytes + if ( + resumeBytes < 0 || + !Number.isSafeInteger(valueBytes) || + valueBytes > AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES + ) { + return null + } + return keyBytes + valueBytes + } catch { + return null + } +} + +function persistedEntry(entry: SessionParseCacheEntry): PersistedSessionParseCacheEntry { + return { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session + } +} + +function deleteSessionParseCacheEntry(path: string): void { + const retained = cache.get(path) + if (!retained) { + return + } + cache.delete(path) + retainedUtf8Bytes -= retained.retainedUtf8Bytes +} + +function newestEntryTail( + entries: Iterable<[string, PersistedSessionParseCacheEntry]> +): [string, PersistedSessionParseCacheEntry][] { + const ring = Array.from({ length: AI_VAULT_PARSE_CACHE_MAX_ENTRIES }) as [ + string, + PersistedSessionParseCacheEntry + ][] + let count = 0 + for (const entry of entries) { + ring[count % AI_VAULT_PARSE_CACHE_MAX_ENTRIES] = entry + count++ + } + const length = Math.min(count, AI_VAULT_PARSE_CACHE_MAX_ENTRIES) + const start = count > length ? count % AI_VAULT_PARSE_CACHE_MAX_ENTRIES : 0 + return Array.from({ length }, (_, index) => ring[(start + index) % ring.length]) +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 3ce4cd1181d..e268d547a73 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -52,6 +52,31 @@ export function cloneSessionAccumulator(accumulator: SessionAccumulator): Sessio return { ...accumulator, previewMessages: [...accumulator.previewMessages] } } +export function sessionAccumulatorRetainedUtf8Bytes(accumulator: SessionAccumulator): number { + let bytes = stringBytes( + accumulator.agent, + accumulator.sessionId, + accumulator.title, + accumulator.fallbackTitle, + accumulator.cwd, + accumulator.branch, + accumulator.model, + accumulator.filePath, + accumulator.createdAt, + accumulator.updatedAt, + accumulator.modifiedAt, + accumulator.lastUserPrompt + ) + for (const preview of accumulator.previewMessages) { + bytes += stringBytes(preview.role, preview.text, preview.timestamp) + } + return bytes +} + +function stringBytes(...values: (string | null)[]): number { + return values.reduce((total, value) => total + Buffer.byteLength(value ?? '', 'utf8'), 0) +} + // Resumable fold for parsers whose only parse state is the accumulator itself // (cursor, copilot, droid, openclaw/pi, gemini-jsonl). Parsers with extra // closure state (claude, codex) build their own ResumableSessionParseState. @@ -63,6 +88,7 @@ export function accumulatorFoldResumeState( consumeLine: (line) => consumeRecordLine(accumulator, line), clone: () => accumulatorFoldResumeState(cloneSessionAccumulator(accumulator), consumeRecordLine), + retainedUtf8Bytes: () => sessionAccumulatorRetainedUtf8Bytes(accumulator), touchFile: (file) => { accumulator.modifiedAt = file.modifiedAt }, @@ -187,23 +213,16 @@ export function updateLatestLocation( accumulator: SessionAccumulator, record: Record ): void { - // Why: a session's representative cwd is its START directory, not its latest. - // `claude --resume ` only finds the transcript under the project dir keyed - // by the start cwd, and history grouping/filtering key off the session origin; - // a later drifted cwd broke resume for sessions that changed directory (#9361). - // Transcripts are append-only, so the first record carrying a cwd is the start. - if (accumulator.cwd === null) { - const startCwd = extractString(record.cwd) - if (startCwd) { - accumulator.cwd = startCwd - } - } const timestamp = extractString(record.timestamp) const parsed = timestamp ? Date.parse(timestamp) : accumulator.latestTimestampMs if (!Number.isFinite(parsed) || parsed < accumulator.latestTimestampMs) { return } + const cwd = extractString(record.cwd) const branch = extractString(record.gitBranch) + if (cwd) { + accumulator.cwd = cwd + } if (branch) { accumulator.branch = branch } diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts index 2e3a3a08b4a..78c04af58f6 100644 --- a/src/main/ai-vault/session-scanner-antigravity-parser.ts +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -1,6 +1,5 @@ -import { createReadStream } from 'node:fs' -import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' import type { ExecutionHostId } from '../../shared/execution-host' import { accumulatorFoldResumeState, @@ -25,10 +24,7 @@ export async function parseAntigravitySessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(file.path) return parseAntigravitySessionLines({ file, lines, platform }) } diff --git a/src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts b/src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts deleted file mode 100644 index bc63f953e3d..00000000000 --- a/src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { scanAiVaultSessions } from './session-scanner' -import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' - -let tempRoots: string[] = [] - -afterEach(async () => { - await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) - tempRoots = [] -}) - -describe('scanAiVaultSessions — Claude cwd drift', () => { - it('resumes a Claude session from its start directory even after the cwd drifts', async () => { - // Regression for #9361: Claude stores transcripts under - // ~/.claude/projects//, and `claude --resume ` only - // looks in the project dir derived from the *current* cwd. If the session - // changed directory mid-run, resuming with the last-seen cwd fails with - // "No conversation found". The session's representative cwd must stay the - // start directory. - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cwd-drift-')) - tempRoots.push(root) - const roots = isolatedScanRoots(root) - await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true }) - - await writeFile( - join(roots.claudeProjectsDir, 'project', 'drift-session.jsonl'), - jsonLines([ - { - type: 'user', - sessionId: 'drift-session', - timestamp: '2026-05-01T10:00:00.000Z', - cwd: '/repo/app', - gitBranch: 'main', - message: { role: 'user', content: 'start here' } - }, - { - type: 'user', - sessionId: 'drift-session', - timestamp: '2026-05-01T10:05:00.000Z', - cwd: '/repo/app/services/api', - gitBranch: 'main', - message: { role: 'user', content: 'now in a subdirectory' } - } - ]) - ) - - const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) - - const claude = result.sessions.find((session) => session.agent === 'claude') - expect(claude).toMatchObject({ - sessionId: 'drift-session', - cwd: '/repo/app', - resumeCommand: "cd '/repo/app' && claude --resume 'drift-session'" - }) - }) -}) diff --git a/src/main/ai-vault/session-scanner-claude-subagents.test.ts b/src/main/ai-vault/session-scanner-claude-subagents.test.ts index eb0f90d9bc9..5a2a2587199 100644 --- a/src/main/ai-vault/session-scanner-claude-subagents.test.ts +++ b/src/main/ai-vault/session-scanner-claude-subagents.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES } from './session-list-retention' import { listClaudeSubagentSessions } from './session-scanner-claude-subagents' import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' @@ -375,6 +376,111 @@ describe('listClaudeSubagentSessions', () => { ]) }) + it('caps an oversized subagent directory to the newest transcript files', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-cap-')) + tempRoots.push(root) + const parentFilePath = join(root, 'project', 'parent-session.jsonl') + const subagentsDir = join(root, 'project', 'parent-session', 'subagents') + + for (const [index, agentId] of ['oldest', 'middle', 'newest'].entries()) { + await writeSubagentTranscript({ + subagentsDir, + agentId, + taskPrompt: `Task for ${agentId}`, + timestamp: `2026-07-05T10:0${index + 1}:00.000Z` + }) + const mtime = new Date(1_700_000_000_000 + index * 1_000) + await utimes(join(subagentsDir, `agent-${agentId}.jsonl`), mtime, mtime) + } + + const result = await listClaudeSubagentSessions({ + parentFilePath, + platform: 'darwin', + maxSessions: 2 + }) + + expect(result.sessions.map((session) => session.title)).toEqual([ + 'Task for newest', + 'Task for middle' + ]) + expect(result.issues).toHaveLength(1) + expect(result.issues[0]?.message).toContain('newest 2 subagent transcripts') + }) + + it('omits a subagent whose retained session metadata exceeds its per-session limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-session-limit-')) + tempRoots.push(root) + const parentFilePath = join(root, 'project', 'parent-session.jsonl') + const subagentsDir = join(root, 'project', 'parent-session', 'subagents') + + await writeJsonlFile(join(subagentsDir, 'agent-oversized.jsonl'), [ + { + type: 'user', + sessionId: 'parent-session', + timestamp: '2026-07-05T10:00:00.000Z', + cwd: 'x'.repeat(AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES + 1), + message: { role: 'user', content: 'Oversized task' } + } + ]) + await writeSubagentTranscript({ + subagentsDir, + agentId: 'retained', + taskPrompt: 'Retained task', + timestamp: '2026-07-05T10:01:00.000Z' + }) + + const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' }) + + expect(result.sessions.map((session) => session.title)).toEqual(['Retained task']) + expect(result.issues).toHaveLength(1) + expect(result.issues[0]?.message).toContain('working directory exceeds') + }) + + it('stops before parsing older transcripts when the aggregate memory limit is full', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-aggregate-limit-')) + tempRoots.push(root) + const parentFilePath = join(root, 'project', 'parent-session.jsonl') + const subagentsDir = join(root, 'project', 'parent-session', 'subagents') + + for (let index = 0; index < 8; index += 1) { + await writeSubagentTranscript({ + subagentsDir, + agentId: `task-${index}`, + taskPrompt: `Task for task-${index}`, + timestamp: `2026-07-05T10:${index.toString().padStart(2, '0')}:00.000Z` + }) + const mtime = new Date(1_700_000_000_000 + index * 1_000) + await utimes(join(subagentsDir, `agent-task-${index}.jsonl`), mtime, mtime) + } + + const unbounded = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' }) + expect(unbounded.issues).toEqual([]) + const newestSessionBytes = Buffer.byteLength(JSON.stringify([unbounded.sessions[0]]), 'utf8') + + await writeJsonlFile(join(subagentsDir, 'agent-unparsed.jsonl'), [ + { + type: 'user', + sessionId: 'parent-session', + timestamp: '2026-07-05T09:00:00.000Z', + cwd: 'x'.repeat(AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES + 1), + message: { role: 'user', content: 'This older task must not be parsed' } + } + ]) + const oldestMtime = new Date(1_699_999_999_000) + await utimes(join(subagentsDir, 'agent-unparsed.jsonl'), oldestMtime, oldestMtime) + + const result = await listClaudeSubagentSessions({ + parentFilePath, + platform: 'darwin', + maxAggregateBytes: newestSessionBytes + }) + + expect(result.sessions.map((session) => session.title)).toEqual(['Task for task-7']) + expect(result.issues).toHaveLength(1) + expect(result.issues[0]?.message).toContain('8 older entries were omitted') + expect(result.issues[0]?.message).toContain('memory limit') + }) + it('returns an empty list when the session never spawned subagents', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-none-')) tempRoots.push(root) diff --git a/src/main/ai-vault/session-scanner-claude-subagents.ts b/src/main/ai-vault/session-scanner-claude-subagents.ts index 5c269d91cf3..f00ca70de80 100644 --- a/src/main/ai-vault/session-scanner-claude-subagents.ts +++ b/src/main/ai-vault/session-scanner-claude-subagents.ts @@ -1,15 +1,18 @@ -import { createReadStream } from 'node:fs' -import { readdir, readFile, stat } from 'node:fs/promises' +import { opendir, stat } from 'node:fs/promises' import { basename, extname, join } from 'node:path' -import { createInterface } from 'node:readline' import type { AiVaultScanIssue, AiVaultSession, AiVaultSubagentListResult, AiVaultSubagentRunStatus } from '../../shared/ai-vault-types' -import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator' +import { retainClaudeSubagentSessionBatch } from './claude-subagent-list-retention' +import { retainAiVaultSession } from './session-list-retention' +import { sessionIdFromFileName } from './session-scanner-accumulator' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' +import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import { parseClaudeSessionFile } from './session-scanner-primary-parsers' +import type { FileWithMtime } from './session-scanner-types' import { isSubagentTranscriptFileName, subagentTranscriptsDirFor, @@ -29,6 +32,9 @@ const SUBAGENT_RUNNING_RECENCY_MS = 5 * 60_000 // Match the main scanner's deliberate parse batching (SESSION_PARSE_CONCURRENCY): // opening every subagent read stream at once stalls over SSH/WSL UNC paths. const SUBAGENT_PARSE_CONCURRENCY = 8 +// A single expanded parent stays bounded; oversized sessions show the newest transcripts. +export const CLAUDE_SUBAGENT_SESSION_MAX = 2_048 +const CLAUDE_SUBAGENT_ISSUE_MAX = 256 const TASK_NOTIFICATION_MARKER = '' const TOOL_USE_RESULT_MARKER = '"toolUseResult"' @@ -63,40 +69,40 @@ export async function listClaudeSubagentSessions(args: { parentFilePath: string platform?: NodeJS.Platform now?: number + maxSessions?: number + maxAggregateBytes?: number }): Promise { const platform = args.platform ?? process.platform const now = args.now ?? Date.now() const issues: AiVaultScanIssue[] = [] const subagentsDir = subagentTranscriptsDirFor(args.parentFilePath) - let entries - try { - entries = await readdir(subagentsDir, { withFileTypes: true }) - } catch { - return { sessions: [], issues } - } - - const transcriptNames = entries - .filter((entry) => isSubagentTranscriptFileName(entry.name, entry.isFile())) - .map((entry) => entry.name) - if (transcriptNames.length === 0) { + const maxSessions = Math.min( + CLAUDE_SUBAGENT_SESSION_MAX, + Math.max(0, Math.floor(args.maxSessions ?? CLAUDE_SUBAGENT_SESSION_MAX)) + ) + const transcriptFiles = await newestSubagentTranscriptFiles(subagentsDir, maxSessions, issues) + if (transcriptFiles.length === 0) { return { sessions: [], issues } } // One pass over the parent transcript resolves every subagent's status. - const statusByAgentId = await collectSubagentTaskStatuses(args.parentFilePath) + const agentIds = new Set( + transcriptFiles.map((file) => subagentIdFromFileName(basename(file.path))) + ) + const statusByAgentId = await collectSubagentTaskStatuses(args.parentFilePath, agentIds) // Why: the layout fixes the parent's sessionId (.jsonl -> subagents/); // deriving it here avoids a subagent transcript with no sessionId records // linking to its own filename-derived id instead of the parent. const parentSessionId = sessionIdFromFileName(args.parentFilePath) - const parsed: (AiVaultSession | null)[] = [] - for (let index = 0; index < transcriptNames.length; index += SUBAGENT_PARSE_CONCURRENCY) { - const batch = transcriptNames.slice(index, index + SUBAGENT_PARSE_CONCURRENCY) + let parsed: AiVaultSession[] = [] + for (let index = 0; index < transcriptFiles.length; index += SUBAGENT_PARSE_CONCURRENCY) { + const batch = transcriptFiles.slice(index, index + SUBAGENT_PARSE_CONCURRENCY) const batchResults = await Promise.all( - batch.map((name) => + batch.map((file) => parseSubagentTranscript({ - filePath: join(subagentsDir, name), - agentId: subagentIdFromFileName(name), + file, + agentId: subagentIdFromFileName(basename(file.path)), parentSessionId, statusByAgentId, now, @@ -105,14 +111,92 @@ export async function listClaudeSubagentSessions(args: { }) ) ) - parsed.push(...batchResults) + const retained = retainClaudeSubagentSessionBatch(parsed, batchResults, args.maxAggregateBytes) + parsed = retained.sessions + if (retained.omitted > 0) { + const unparsed = transcriptFiles.length - index - batch.length + addClaudeSubagentIssue( + issues, + subagentsDir, + `Showing the newest ${parsed.length.toLocaleString()} subagent transcripts; ${(retained.omitted + unparsed).toLocaleString()} older entries were omitted because the list reached its memory limit.` + ) + break + } } - return { - sessions: parsed - .filter((session): session is AiVaultSession => session !== null) - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)), - issues + return { sessions: parsed, issues } +} + +async function newestSubagentTranscriptFiles( + subagentsDir: string, + limit: number, + issues: AiVaultScanIssue[] +): Promise { + let directory + try { + directory = await opendir(subagentsDir) + } catch { + return [] + } + + const files: FileWithMtime[] = [] + let validFileCount = 0 + try { + for await (const entry of directory) { + if (!isSubagentTranscriptFileName(entry.name, entry.isFile())) { + continue + } + const path = join(subagentsDir, entry.name) + try { + const fileStat = await stat(path) + validFileCount += 1 + retainNewestSubagentFile(files, limit, { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size, + dev: fileStat.dev, + ino: fileStat.ino, + nlink: fileStat.nlink + }) + } catch (err) { + addClaudeSubagentIssue(issues, path, errorMessage(err)) + } + } + } catch { + // A directory disappearing mid-scan leaves the successfully retained prefix usable. + } + if (validFileCount > limit) { + addClaudeSubagentIssue( + issues, + subagentsDir, + `Showing the newest ${limit.toLocaleString()} subagent transcripts; older entries were omitted.` + ) + } + return files +} + +function retainNewestSubagentFile( + files: FileWithMtime[], + limit: number, + file: FileWithMtime +): void { + let low = 0 + let high = files.length + while (low < high) { + const middle = Math.floor((low + high) / 2) + if ((files[middle]?.mtimeMs ?? Number.NEGATIVE_INFINITY) >= file.mtimeMs) { + low = middle + 1 + } else { + high = middle + } + } + if (low >= limit) { + return + } + files.splice(low, 0, file) + if (files.length > limit) { + files.pop() } } @@ -121,7 +205,7 @@ function subagentIdFromFileName(name: string): string { } async function parseSubagentTranscript(args: { - filePath: string + file: FileWithMtime agentId: string parentSessionId: string statusByAgentId: ReadonlyMap @@ -130,20 +214,12 @@ async function parseSubagentTranscript(args: { issues: AiVaultScanIssue[] }): Promise { try { - const fileStat = await stat(args.filePath) - const session = await parseClaudeSessionFile( - { - path: args.filePath, - mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString() - }, - args.platform - ) + const session = await parseClaudeSessionFile(args.file, args.platform) if (!session) { return null } - const meta = await readSubagentMeta(args.filePath) - return { + const meta = await readSubagentMeta(args.file.path) + return retainAiVaultSession({ ...session, // Why: the spawn description is the name the main agent gave this Task; // it beats the transcript-derived fallback (the raw Task prompt). @@ -155,13 +231,13 @@ async function parseSubagentTranscript(args: { agentType: meta.agentType, status: resolveSubagentStatus({ reportedStatus: args.statusByAgentId.get(args.agentId), - mtimeMs: fileStat.mtimeMs, + mtimeMs: args.file.mtimeMs, now: args.now }) } - } + }) } catch (err) { - args.issues.push({ agent: 'claude', path: args.filePath, message: errorMessage(err) }) + addClaudeSubagentIssue(args.issues, args.file.path, errorMessage(err)) return null } } @@ -185,13 +261,13 @@ function resolveSubagentStatus(args: { // launch with status 'async_launched' and finish with a // whose is the agentId. Last record wins, so interim statuses (e.g. // 'async_launched', notification 'running') are superseded by terminal ones. -async function collectSubagentTaskStatuses(parentFilePath: string): Promise> { +async function collectSubagentTaskStatuses( + parentFilePath: string, + requestedAgentIds: ReadonlySet +): Promise> { const statuses = new Map() try { - const lines = createInterface({ - input: createReadStream(parentFilePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(parentFilePath) for await (const line of lines) { const hasNotification = line.includes(TASK_NOTIFICATION_MARKER) const hasTaskResult = @@ -217,7 +293,7 @@ async function collectSubagentTaskStatuses(parentFilePath: string): Promise marker follows / lines in real // notifications, so it sits well past the 96-char title cap — the notification // text must be read untruncated (unlike titles/previews). Both delivery shapes @@ -269,11 +351,13 @@ function taskNotificationBlockText(block: unknown): string { async function readSubagentMeta(transcriptPath: string): Promise { const metaPath = `${transcriptPath.slice(0, -extname(transcriptPath).length)}.meta.json` try { - const record = asRecord(JSON.parse(await readFile(metaPath, 'utf-8')) as unknown) - return { - description: normalizeTitleText(extractString(record?.description) ?? ''), - agentType: extractString(record?.agentType) - } + return await withAiVaultWholeJsonFile(metaPath, (content) => { + const record = parseJsonObject(content) + return { + description: normalizeTitleText(extractString(record?.description) ?? ''), + agentType: extractString(record?.agentType) + } + }) } catch { // The sidecar is optional; the transcript still yields a usable title. return { description: null, agentType: null } diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 7e08ec9d92e..cbe9fcebe1a 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -1,7 +1,6 @@ -import { createReadStream } from 'node:fs' -import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' import type { ExecutionHostId } from '../../shared/execution-host' import { normalizePromptField } from '../../shared/agent-status-field-normalization' import { @@ -9,6 +8,7 @@ import { cloneSessionAccumulator, createAccumulator, finalizeSession, + sessionAccumulatorRetainedUtf8Bytes, sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' @@ -38,10 +38,7 @@ export async function parseCodexSessionFile( codexHome: string | null = null, executionHostId?: ExecutionHostId ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(file.path) return parseCodexSessionLines({ file, @@ -276,6 +273,7 @@ function codexResumeStateFromParseState( consumeLine: (line) => consumeCodexRecordLine(state, line), clone: () => codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader), + retainedUtf8Bytes: () => sessionAccumulatorRetainedUtf8Bytes(state.accumulator), touchFile: (file) => { state.accumulator.modifiedAt = file.modifiedAt }, diff --git a/src/main/ai-vault/session-scanner-codex-title-index.test.ts b/src/main/ai-vault/session-scanner-codex-title-index.test.ts index 3c5b129b5fc..f011c0605da 100644 --- a/src/main/ai-vault/session-scanner-codex-title-index.test.ts +++ b/src/main/ai-vault/session-scanner-codex-title-index.test.ts @@ -4,14 +4,18 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { _getCodexSessionIndexTitleCacheSizeForTest, + _getCodexSessionIndexRetainedTitleCountForTest, _hasCodexSessionIndexTitleCacheEntryForTest, _readCachedCodexSessionIndexTitlesForTest, _storeCodexSessionIndexTitleCacheEntryForTest, + CODEX_SESSION_INDEX_RETAINED_UTF8_BYTES_MAX, + CODEX_SESSION_INDEX_SESSION_ID_MAX_UTF8_BYTES, readCodexSessionIndexTitle, resetCodexSessionIndexTitleCacheForTests } from './session-scanner-codex-title-index' const CACHE_LIMIT = 64 +const RETAINED_TITLE_LIMIT = 2_048 let tempRoots: string[] = [] @@ -42,6 +46,42 @@ async function readTitle(codexHome: string, index: number): Promise { + it('bounds retained titles without dropping an older requested title', async () => { + const codexHome = await createCodexHome(0) + const lines = Array.from({ length: RETAINED_TITLE_LIMIT + 1 }, (_, index) => + JSON.stringify({ id: `session-${index}`, thread_name: `Title ${index}` }) + ) + await writeFile(join(codexHome, 'session_index.jsonl'), `${lines.join('\n')}\n`) + + expect(await readTitle(codexHome, 0)).toBe('Title 0') + expect(await _getCodexSessionIndexRetainedTitleCountForTest(codexHome)).toBe( + RETAINED_TITLE_LIMIT + ) + }) + + it('bounds retained title-key bytes without dropping an oversized requested id', async () => { + const codexHome = await createCodexHome(0) + const ids = Array.from({ length: 9 }, (_, index) => + `${index}`.padEnd(CODEX_SESSION_INDEX_SESSION_ID_MAX_UTF8_BYTES, 'x') + ) + const oversizedId = `${ids[0]}x` + const lines = [ + JSON.stringify({ id: oversizedId, thread_name: 'Oversized requested title' }), + ...ids.map((id, index) => JSON.stringify({ id, thread_name: `Title ${index}` })) + ] + await writeFile(join(codexHome, 'session_index.jsonl'), `${lines.join('\n')}\n`) + + await expect( + readCodexSessionIndexTitle( + join(codexHome, 'sessions', `${oversizedId}.jsonl`), + codexHome, + oversizedId + ) + ).resolves.toBe('Oversized requested title') + expect(await _getCodexSessionIndexRetainedTitleCountForTest(codexHome)).toBeLessThan(9) + expect(CODEX_SESSION_INDEX_RETAINED_UTF8_BYTES_MAX).toBe(512 * 1024) + }) + it('caps cached title indexes by Codex home', async () => { const homes: string[] = [] diff --git a/src/main/ai-vault/session-scanner-codex-title-index.ts b/src/main/ai-vault/session-scanner-codex-title-index.ts index 8ef2d5645cb..67ee4f0b499 100644 --- a/src/main/ai-vault/session-scanner-codex-title-index.ts +++ b/src/main/ai-vault/session-scanner-codex-title-index.ts @@ -1,16 +1,22 @@ -import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import { createInterface } from 'node:readline' import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' // Codex names threads lazily in /session_index.jsonl; transcripts // carry no title of their own, so parsers look the thread name up here. const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' +// Misses fall back to an exact streaming lookup, so this only trades memory for scan time. +const CODEX_SESSION_INDEX_RETAINED_TITLE_MAX = 2_048 +export const CODEX_SESSION_INDEX_CACHE_KEY_MAX_UTF8_BYTES = 32 * 1024 +export const CODEX_SESSION_INDEX_SESSION_ID_MAX_UTF8_BYTES = 64 * 1024 +export const CODEX_SESSION_INDEX_RETAINED_UTF8_BYTES_MAX = 512 * 1024 +const CODEX_SESSION_INDEX_TITLE_MAX_UTF8_BYTES = 4 * 1024 +const CODEX_SESSION_INDEX_ENTRY_OVERHEAD_BYTES = 64 // Why: custom and WSL Codex homes can vary over a long-lived main process; -// each cached home can retain a full session_index title map. -const CODEX_SESSION_INDEX_TITLE_CACHE_MAX = 64 +// each cached home can retain its own session_index title map. +export const CODEX_SESSION_INDEX_TITLE_CACHE_MAX = 64 type CodexSessionIndexTitleCacheEntry = { signature: string @@ -18,6 +24,7 @@ type CodexSessionIndexTitleCacheEntry = { } const codexSessionIndexTitleCache = new Map>() +const retainedBytesByTitleMap = new WeakMap, number>() export function resetCodexSessionIndexTitleCacheForTests(): void { codexSessionIndexTitleCache.clear() @@ -31,6 +38,12 @@ export function _hasCodexSessionIndexTitleCacheEntryForTest(codexHome: string): return codexSessionIndexTitleCache.has(codexHome) } +export async function _getCodexSessionIndexRetainedTitleCountForTest( + codexHome: string +): Promise { + return (await codexSessionIndexTitleCache.get(codexHome))?.titles.size ?? 0 +} + export function _storeCodexSessionIndexTitleCacheEntryForTest( codexHome: string, signature: string, @@ -59,7 +72,21 @@ export async function readCodexSessionIndexTitle( return null } const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome) - return titleBySessionId.get(sessionId) ?? null + const cachedTitle = titleBySessionId.get(sessionId) + if (cachedTitle) { + return cachedTitle + } + + // Why: the retained map is capped, but an older requested session must still + // get the same title as before the cap. + const title = await readCodexSessionIndexTitleFromDisk( + join(resolvedCodexHome, CODEX_SESSION_INDEX_FILE), + sessionId + ) + if (title) { + retainCodexSessionIndexTitle(titleBySessionId, sessionId, title) + } + return title } function codexHomeFromSessionFilePath(sessionFilePath: string): string | null { @@ -121,8 +148,15 @@ function storeCodexSessionIndexTitleCacheEntry( codexHome: string, pending: Promise ): void { + if (Buffer.byteLength(codexHome, 'utf8') > CODEX_SESSION_INDEX_CACHE_KEY_MAX_UTF8_BYTES) { + return + } + const boundedPending = pending.then((entry) => { + boundCodexSessionIndexTitles(entry.titles) + return entry + }) codexSessionIndexTitleCache.delete(codexHome) - codexSessionIndexTitleCache.set(codexHome, pending) + codexSessionIndexTitleCache.set(codexHome, boundedPending) if (codexSessionIndexTitleCache.size > CODEX_SESSION_INDEX_TITLE_CACHE_MAX) { const oldest = codexSessionIndexTitleCache.keys().next() if (!oldest.done) { @@ -136,10 +170,7 @@ async function readCodexSessionIndexTitlesFromDisk( ): Promise> { const titleBySessionId = new Map() try { - const lines = createInterface({ - input: createReadStream(indexPath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(indexPath) for await (const line of lines) { const record = parseJsonObject(line) if (!record) { @@ -148,7 +179,7 @@ async function readCodexSessionIndexTitlesFromDisk( const sessionId = extractString(record.id) const title = normalizeTitleText(extractString(record.thread_name) ?? '') if (sessionId && title) { - titleBySessionId.set(sessionId, title) + retainCodexSessionIndexTitle(titleBySessionId, sessionId, title) } } } catch { @@ -156,3 +187,78 @@ async function readCodexSessionIndexTitlesFromDisk( } return titleBySessionId } + +async function readCodexSessionIndexTitleFromDisk( + indexPath: string, + requestedSessionId: string +): Promise { + let requestedTitle: string | null = null + try { + for await (const line of iterateAiVaultJsonlLines(indexPath)) { + const record = parseJsonObject(line) + if (record && extractString(record.id) === requestedSessionId) { + requestedTitle = + normalizeTitleText(extractString(record.thread_name) ?? '') ?? requestedTitle + } + } + } catch { + // Match the best-effort behavior of the full index read. + } + return requestedTitle +} + +export function retainCodexSessionIndexTitle( + titles: Map, + sessionId: string, + title: string +): void { + boundCodexSessionIndexTitles(titles) + const sessionIdBytes = Buffer.byteLength(sessionId, 'utf8') + const titleBytes = Buffer.byteLength(title, 'utf8') + if ( + sessionIdBytes > CODEX_SESSION_INDEX_SESSION_ID_MAX_UTF8_BYTES || + titleBytes > CODEX_SESSION_INDEX_TITLE_MAX_UTF8_BYTES + ) { + return + } + let retainedBytes = retainedBytesByTitleMap.get(titles) ?? 0 + const existing = titles.get(sessionId) + if (existing !== undefined) { + retainedBytes -= codexTitleEntryBytes(sessionId, existing) + } + titles.delete(sessionId) + const entryBytes = sessionIdBytes + titleBytes + CODEX_SESSION_INDEX_ENTRY_OVERHEAD_BYTES + while ( + titles.size >= CODEX_SESSION_INDEX_RETAINED_TITLE_MAX || + retainedBytes + entryBytes > CODEX_SESSION_INDEX_RETAINED_UTF8_BYTES_MAX + ) { + const oldest = titles.entries().next().value + if (!oldest) { + break + } + titles.delete(oldest[0]) + retainedBytes -= codexTitleEntryBytes(oldest[0], oldest[1]) + } + titles.set(sessionId, title) + retainedBytesByTitleMap.set(titles, retainedBytes + entryBytes) +} + +function boundCodexSessionIndexTitles(titles: Map): void { + if (retainedBytesByTitleMap.has(titles)) { + return + } + const entries = [...titles] + titles.clear() + retainedBytesByTitleMap.set(titles, 0) + for (const [sessionId, title] of entries) { + retainCodexSessionIndexTitle(titles, sessionId, title) + } +} + +function codexTitleEntryBytes(sessionId: string, title: string): number { + return ( + Buffer.byteLength(sessionId, 'utf8') + + Buffer.byteLength(title, 'utf8') + + CODEX_SESSION_INDEX_ENTRY_OVERHEAD_BYTES + ) +} diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index 4ad6fa45f78..81a8bdbc860 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -1,6 +1,6 @@ -import { readFile } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' +import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import type { FileWithMtime } from './session-scanner-types' import { addPreviewContent, @@ -15,7 +15,8 @@ import { extractContentText, extractString, normalizeTitleText, - numberValue + numberValue, + parseJsonObject } from './session-scanner-values' type ParserSessionOptions = { @@ -27,7 +28,9 @@ export async function parseDevinSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform) + return withAiVaultWholeJsonFile(file.path, (content) => + parseDevinSessionContent(file, content, platform) + ) } export function parseDevinSessionContent( @@ -36,7 +39,7 @@ export function parseDevinSessionContent( platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {} ): AiVaultSession | null { - const record = asRecord(JSON.parse(content) as unknown) + const record = parseJsonObject(content) if (!record) { return null } diff --git a/src/main/ai-vault/session-scanner-discovery.test.ts b/src/main/ai-vault/session-scanner-discovery.test.ts new file mode 100644 index 00000000000..0422134ad3b --- /dev/null +++ b/src/main/ai-vault/session-scanner-discovery.test.ts @@ -0,0 +1,162 @@ +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { discoverFiles } from './session-scanner-discovery' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('AI Vault session discovery retention', () => { + it('retains only the newest requested files while traversing nested directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-discovery-')) + tempRoots.push(root) + const nested = join(root, 'nested') + await mkdir(nested) + + for (let index = 0; index < 40; index += 1) { + const path = join(index % 2 === 0 ? root : nested, `session-${index}.jsonl`) + await writeFile(path, '{}\n') + const timestamp = new Date(1_700_000_000_000 + index * 1_000) + await utimes(path, timestamp, timestamp) + } + + const result = await discoverFiles({ + rootDir: root, + limit: 3, + agent: 'codex', + issues: [], + extensions: ['.jsonl'] + }) + + expect(result.files).toHaveLength(3) + expect(result.files.map((file) => basename(file.path))).toEqual([ + 'session-39.jsonl', + 'session-38.jsonl', + 'session-37.jsonl' + ]) + }) + + it('returns no retained paths when the requested limit is zero', async () => { + const result = await discoverFiles({ + rootDir: join('path', 'that', 'does-not-need-to-be-read'), + limit: 0, + agent: 'codex', + issues: [], + extensions: ['.jsonl'] + }) + + expect(result.files).toEqual([]) + }) + + it('accepts the exact aggregate entry capacity without changing results', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-discovery-')) + tempRoots.push(root) + await Promise.all([ + writeFile(join(root, 'session-a.jsonl'), '{}\n'), + writeFile(join(root, 'session-b.jsonl'), '{}\n') + ]) + const issues: AiVaultScanIssue[] = [] + + const result = await discoverFiles({ + rootDir: root, + limit: 10, + agent: 'codex', + issues, + extensions: ['.jsonl'], + limits: { maxEntries: 2 } + }) + + expect(result.files.map((file) => basename(file.path)).sort()).toEqual([ + 'session-a.jsonl', + 'session-b.jsonl' + ]) + expect(issues).toEqual([]) + }) + + it('reports aggregate entry overflow and releases the open directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-discovery-')) + tempRoots.push(root) + await Promise.all([ + writeFile(join(root, 'session-a.jsonl'), '{}\n'), + writeFile(join(root, 'session-b.jsonl'), '{}\n'), + writeFile(join(root, 'session-c.jsonl'), '{}\n') + ]) + const issues: AiVaultScanIssue[] = [] + + const result = await discoverFiles({ + rootDir: root, + limit: 10, + agent: 'codex', + issues, + extensions: ['.jsonl'], + limits: { maxEntries: 2 } + }) + + const retainedNames = result.files.map((file) => basename(file.path)) + expect(retainedNames).toHaveLength(2) + expect( + retainedNames.every((name) => + ['session-a.jsonl', 'session-b.jsonl', 'session-c.jsonl'].includes(name) + ) + ).toBe(true) + expect(issues).toEqual([ + expect.objectContaining({ + agent: 'codex', + path: root, + message: expect.stringContaining('safety limit') + }) + ]) + await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined() + tempRoots = tempRoots.filter((candidate) => candidate !== root) + }) + + it('reports a depth overflow before opening the over-limit directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-discovery-')) + tempRoots.push(root) + const overLimit = join(root, 'level-one', 'level-two') + await mkdir(overLimit, { recursive: true }) + await writeFile(join(overLimit, 'session.jsonl'), '{}\n') + const issues: AiVaultScanIssue[] = [] + + const result = await discoverFiles({ + rootDir: root, + limit: 10, + agent: 'codex', + issues, + extensions: ['.jsonl'], + limits: { maxDepth: 1 } + }) + + expect(result.files).toEqual([]) + expect(issues).toEqual([ + expect.objectContaining({ path: root, message: expect.stringContaining('safety limit') }) + ]) + }) + + it('accepts a file at the exact folder-depth capacity', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-discovery-')) + tempRoots.push(root) + const exactLimit = join(root, 'level-one') + await mkdir(exactLimit) + await writeFile(join(exactLimit, 'session.jsonl'), '{}\n') + const issues: AiVaultScanIssue[] = [] + + const result = await discoverFiles({ + rootDir: root, + limit: 10, + agent: 'codex', + issues, + extensions: ['.jsonl'], + limits: { maxDepth: 1 } + }) + + expect(result.files.map((file) => basename(file.path))).toEqual(['session.jsonl']) + expect(issues).toEqual([]) + }) +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index d93d6f01fea..90622baa974 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,8 +1,15 @@ -import { readdir, stat } from 'node:fs/promises' +import { opendir, stat } from 'node:fs/promises' import { basename, delimiter, extname, join } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +import { + AiVaultSessionDiscoveryBudget, + AiVaultSessionDiscoveryCapacityError, + type AiVaultSessionDiscoveryLimits +} from './session-discovery-budget' + +const AI_VAULT_DISCOVERY_ISSUE_MAX = 256 export async function discoverFiles(args: { rootDir: string @@ -12,33 +19,106 @@ export async function discoverFiles(args: { extensions: string[] filePredicate?: (path: string) => boolean directoryPredicate?: (name: string, depth: number) => boolean + limits?: Partial }): Promise { - const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { - extensions: new Set(args.extensions), - filePredicate: args.filePredicate, - directoryPredicate: args.directoryPredicate - }) const files: FileWithMtime[] = [] - for (const path of paths) { + const limit = Math.max(0, Math.floor(args.limit)) + if (limit > 0) { try { - const fileStat = await stat(path) - files.push({ - path, - mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString(), - sizeBytes: fileStat.size, - dev: fileStat.dev, - ino: fileStat.ino, - nlink: fileStat.nlink - }) - } catch (err) { - args.issues.push({ agent: args.agent, path, message: errorMessage(err) }) + await visitSessionFiles( + args.rootDir, + { + extensions: new Set(args.extensions), + filePredicate: args.filePredicate, + directoryPredicate: args.directoryPredicate + }, + async (path) => { + try { + const fileStat = await stat(path) + retainNewestFile(files, limit, { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size, + dev: fileStat.dev, + ino: fileStat.ino, + nlink: fileStat.nlink + }) + } catch (err) { + addDiscoveryIssue(args.issues, args.agent, path, errorMessage(err)) + } + return true + }, + new AiVaultSessionDiscoveryBudget(args.limits) + ) + } catch (error) { + if (error instanceof AiVaultSessionDiscoveryCapacityError) { + addDiscoveryIssue(args.issues, args.agent, args.rootDir, error.message) + } else { + throw error + } } } - return { - agent: args.agent, - rootDir: args.rootDir, - files: files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, args.limit) + return { agent: args.agent, rootDir: args.rootDir, files } +} + +type SessionFileTraversalOptions = { + extensions: Set + filePredicate?: (path: string) => boolean + directoryPredicate?: (name: string, depth: number) => boolean +} + +export async function findFirstSessionFile( + dirPath: string, + options: SessionFileTraversalOptions +): Promise { + let found: string | null = null + try { + await visitSessionFiles( + dirPath, + options, + async (path) => { + found = path + return false + }, + new AiVaultSessionDiscoveryBudget() + ) + } catch (error) { + if (!(error instanceof AiVaultSessionDiscoveryCapacityError)) { + throw error + } + } + return found +} + +function addDiscoveryIssue( + issues: AiVaultScanIssue[], + agent: AiVaultAgent, + path: string, + message: string +): void { + if (issues.length < AI_VAULT_DISCOVERY_ISSUE_MAX) { + issues.push({ agent, path, message }) + } +} + +function retainNewestFile(files: FileWithMtime[], limit: number, file: FileWithMtime): void { + let low = 0 + let high = files.length + while (low < high) { + const middle = Math.floor((low + high) / 2) + if ((files[middle]?.mtimeMs ?? Number.NEGATIVE_INFINITY) >= file.mtimeMs) { + low = middle + 1 + } else { + high = middle + } + } + if (low >= limit) { + return + } + files.splice(low, 0, file) + if (files.length > limit) { + files.pop() } } @@ -47,63 +127,94 @@ export async function discoverOpenClawFiles(args: { limit: number issues: AiVaultScanIssue[] }): Promise { - const discoveries = await Promise.all( - args.rootDirs.map((rootDir) => - discoverFiles({ - rootDir: basename(rootDir) === 'agents' ? rootDir : join(rootDir, 'agents'), - limit: args.limit, - agent: 'openclaw', - issues: args.issues, - extensions: ['.jsonl'], - filePredicate: (path) => path.split(/[\\/]/).includes('sessions') - }) - ) - ) - const files = discoveries - .flatMap((discovery) => discovery.files) - .sort((left, right) => right.mtimeMs - left.mtimeMs) - .slice(0, args.limit) + const files: FileWithMtime[] = [] + const limit = Math.max(0, Math.floor(args.limit)) + for (const rootDir of args.rootDirs) { + const discovery = await discoverFiles({ + rootDir: basename(rootDir) === 'agents' ? rootDir : join(rootDir, 'agents'), + limit, + agent: 'openclaw', + issues: args.issues, + extensions: ['.jsonl'], + filePredicate: (path) => path.split(/[\\/]/).includes('sessions') + }) + for (const file of discovery.files) { + retainNewestFile(files, limit, file) + } + } return { agent: 'openclaw', rootDir: args.rootDirs.join(delimiter), files } } -export async function walkSessionFiles( +async function visitSessionFiles( dirPath: string, - agent: AiVaultAgent, - issues: AiVaultScanIssue[], - options: { - extensions: Set - filePredicate?: (path: string) => boolean - // Return false to skip descending into a directory; depth 0 is a child of - // rootDir, so pruned subtrees are never stat'd or parsed. - directoryPredicate?: (name: string, depth: number) => boolean - }, + options: SessionFileTraversalOptions, + visitFile: (path: string) => Promise, + budget: AiVaultSessionDiscoveryBudget, depth = 0 -): Promise { - let entries +): Promise { + budget.enterDirectory(depth) + let directory try { - entries = await readdir(dirPath, { withFileTypes: true }) + directory = await opendir(dirPath) } catch { - return [] + return true } - const files: string[] = [] - for (const entry of entries) { - const fullPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - // Skip whole subtrees an agent never wants (e.g. subagent transcripts), - // avoiding the readdir cost of descending into them. - if (options.directoryPredicate?.(entry.name, depth) ?? true) { - files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1))) + const tasks: { kind: 'directory' | 'file'; path: string }[] = [] + let capacityError: AiVaultSessionDiscoveryCapacityError | null = null + try { + while (true) { + let entry + try { + entry = await directory.read() + } catch { + // A disappearing directory has no safe remainder to visit. + break + } + if (!entry) { + break + } + const fullPath = join(dirPath, entry.name) + try { + budget.visitEntry(fullPath) + } catch (error) { + if (error instanceof AiVaultSessionDiscoveryCapacityError) { + capacityError = error + break + } + throw error + } + if (entry.isDirectory()) { + // Skip whole subtrees an agent never wants (e.g. subagent transcripts), + // avoiding the directory-read cost of descending into them. + if (options.directoryPredicate?.(entry.name, depth) ?? true) { + tasks.push({ kind: 'directory', path: fullPath }) + } + continue + } + if ( + entry.isFile() && + options.extensions.has(extname(entry.name).toLowerCase()) && + (options.filePredicate?.(fullPath) ?? true) + ) { + tasks.push({ kind: 'file', path: fullPath }) } - continue } - if ( - entry.isFile() && - options.extensions.has(extname(entry.name).toLowerCase()) && - (options.filePredicate?.(fullPath) ?? true) - ) { - files.push(fullPath) + } finally { + await directory.close().catch(() => undefined) + } + + for (const task of tasks) { + const shouldContinue = + task.kind === 'directory' + ? await visitSessionFiles(task.path, options, visitFile, budget, depth + 1) + : await visitFile(task.path) + if (!shouldContinue) { + return false } } - return files + if (capacityError) { + throw capacityError + } + return true } diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts index 2abaa7b8193..bd1f83f954c 100644 --- a/src/main/ai-vault/session-scanner-droid-parser.ts +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -1,7 +1,6 @@ -import { createReadStream } from 'node:fs' -import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' import type { FileWithMtime, ResumableSessionParseState, @@ -33,10 +32,7 @@ export async function parseDroidSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(file.path) return parseDroidSessionLines({ file, lines, platform }) } diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts index 6f0b9fdc7c1..7b398d81783 100644 --- a/src/main/ai-vault/session-scanner-gemini-parsers.ts +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -1,7 +1,6 @@ -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' -import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' +import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import type { FileWithMtime, ResumableParseFinalizeOptions, @@ -33,7 +32,9 @@ export async function parseGeminiSessionFile( return parseGeminiJsonlSessionFile(file, platform) } - return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform) + return withAiVaultWholeJsonFile(file.path, (content) => + parseGeminiJsonSessionContent(file, content, platform) + ) } export async function parseGeminiSessionContent( @@ -59,7 +60,7 @@ function parseGeminiJsonSessionContent( platform: NodeJS.Platform, options: ResumableParseFinalizeOptions = {} ): AiVaultSession | null { - const record = asRecord(JSON.parse(content) as unknown) + const record = parseJsonObject(content) if (!record) { return null } @@ -80,10 +81,7 @@ export async function parseGeminiJsonlSessionFile( file: FileWithMtime, platform: NodeJS.Platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(file.path) return parseGeminiJsonlSessionLines({ file, lines, platform }) } diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 04601eacde5..d128cb89904 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,9 +1,8 @@ -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' +import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import type { FileWithMtime, ResumableSessionParseState, @@ -26,7 +25,6 @@ import { extractString, firstString, parseJsonObject, - readJsonObjectIfExists, tokenTotal } from './session-scanner-values' @@ -39,39 +37,45 @@ export async function parseRovoSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const metadata = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) - if (!metadata) { + const accumulator = await withAiVaultWholeJsonFile(file.path, (content) => { + const metadata = parseJsonObject(content) + if (!metadata) { + return null + } + const next = createAccumulator({ + agent: 'rovo', + file, + sessionId: basename(dirname(file.path)) + }) + next.title = firstString(metadata, ['title', 'name', 'summary']) + next.cwd = firstString(metadata, [ + 'workspace_path', + 'workspacePath', + 'workspace', + 'cwd', + 'working_directory', + 'workingDirectory', + 'project_path', + 'projectPath' + ]) + updateTimeline(next, extractString(metadata.created_at) ?? extractString(metadata.createdAt)) + updateTimeline(next, extractString(metadata.updated_at) ?? extractString(metadata.updatedAt)) + return next + }) + if (!accumulator) { return null } - const accumulator = createAccumulator({ - agent: 'rovo', - file, - sessionId: basename(dirname(file.path)) - }) - accumulator.title = firstString(metadata, ['title', 'name', 'summary']) - accumulator.cwd = firstString(metadata, [ - 'workspace_path', - 'workspacePath', - 'workspace', - 'cwd', - 'working_directory', - 'workingDirectory', - 'project_path', - 'projectPath' - ]) - updateTimeline( - accumulator, - extractString(metadata.created_at) ?? extractString(metadata.createdAt) - ) - updateTimeline( - accumulator, - extractString(metadata.updated_at) ?? extractString(metadata.updatedAt) - ) const contextPath = join(dirname(file.path), 'session_context.json') - const context = await readJsonObjectIfExists(contextPath) - if (context) { - consumeRovoSessionContext(accumulator, context) + try { + await withAiVaultWholeJsonFile(contextPath, (content) => { + const context = parseJsonObject(content) + if (context) { + consumeRovoSessionContext(accumulator, context) + } + }) + } catch { + // Optional context may not exist yet; metadata still yields a useful row. } return finalizeSession(accumulator, platform) @@ -172,10 +176,7 @@ export async function parseMessageGraphSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(file.path) return parseMessageGraphSessionLines({ agent, file, lines, platform }) } diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index 13620007447..84d23f7a115 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -1,8 +1,7 @@ -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' -import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' +import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewMessage, @@ -27,24 +26,29 @@ export async function parseGrokSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) - if (!record) { + const accumulator = await withAiVaultWholeJsonFile(file.path, (content) => { + const record = parseJsonObject(content) + if (!record) { + return null + } + const info = asRecord(record.info) + const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path)) + const next = createAccumulator({ agent: 'grok', file, sessionId }) + next.cwd = extractString(info?.cwd) + next.title = + normalizeTitleText(extractString(record.generated_title) ?? '') ?? + normalizeTitleText(extractString(record.session_summary) ?? '') + next.model = extractString(record.current_model_id) + next.branch = extractString(record.head_branch) + next.messageCount = numberValue(record.num_chat_messages) || numberValue(record.num_messages) + updateTimeline(next, extractString(record.created_at)) + updateTimeline(next, extractString(record.updated_at)) + updateTimeline(next, extractString(record.last_active_at)) + return next + }) + if (!accumulator) { return null } - const info = asRecord(record.info) - const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path)) - const accumulator = createAccumulator({ agent: 'grok', file, sessionId }) - accumulator.cwd = extractString(info?.cwd) - accumulator.title = - normalizeTitleText(extractString(record.generated_title) ?? '') ?? - normalizeTitleText(extractString(record.session_summary) ?? '') - accumulator.model = extractString(record.current_model_id) - accumulator.branch = extractString(record.head_branch) - accumulator.messageCount = - numberValue(record.num_chat_messages) || numberValue(record.num_messages) - updateTimeline(accumulator, extractString(record.created_at)) - updateTimeline(accumulator, extractString(record.updated_at)) - updateTimeline(accumulator, extractString(record.last_active_at)) await consumeGrokChatHistory(accumulator, dirname(file.path)) return finalizeSession(accumulator, platform) } @@ -54,10 +58,7 @@ async function consumeGrokChatHistory( sessionDir: string ): Promise { try { - const lines = createInterface({ - input: createReadStream(join(sessionDir, 'chat_history.jsonl'), { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(join(sessionDir, 'chat_history.jsonl')) for await (const line of lines) { const record = parseJsonObject(line) diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts index 549c1a3c170..6ac75daa429 100644 --- a/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts @@ -4,13 +4,18 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { KimiSessionIndexCache, + KIMI_WORK_DIR_CACHE_KEY_MAX_UTF8_BYTES, KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + KIMI_WORK_DIR_MAP_MAX_RETAINED_UTF8_BYTES, + KIMI_WORK_DIR_SESSION_ID_MAX_UTF8_BYTES, KIMI_WORK_DIR_CACHE_TTL_MS, type KimiSessionIndexIdentity } from './session-scanner-kimi-index-cache' import { clearKimiSessionIndexCache, hasKimiSessionIndexCacheEntryForTests, + KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX, + readKimiWorkDirForSessionId, readKimiWorkDirBySessionId } from './session-scanner-kimi-paths' @@ -121,9 +126,42 @@ describe('KimiSessionIndexCache', () => { expect(cache.has('index')).toBe(true) cache.clear() }) + + it('bounds index keys and retained map bytes without changing returned values', async () => { + const cache = new KimiSessionIndexCache() + const oversizedPath = 'x'.repeat(KIMI_WORK_DIR_CACHE_KEY_MAX_UTF8_BYTES + 1) + const oversizedId = 'x'.repeat(KIMI_WORK_DIR_SESSION_ID_MAX_UTF8_BYTES + 1) + const value = new Map([ + [oversizedId, '/oversized'], + ['session', '/repo'] + ]) + + await expect( + cache.get(oversizedPath, IDENTITY, cache.beginRead(), async () => value) + ).resolves.toBe(value) + expect(value).toEqual(new Map([['session', '/repo']])) + expect(cache.has(oversizedPath)).toBe(false) + expect(KIMI_WORK_DIR_MAP_MAX_RETAINED_UTF8_BYTES).toBe(512 * 1024) + }) }) describe('Kimi session index reader cache', () => { + it('bounds retained work dirs without dropping an older requested session', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-bounds-')) + tempDirs.push(root) + const indexPath = join(root, 'session_index.jsonl') + const lines = Array.from( + { length: KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX + 1 }, + (_, index) => JSON.stringify({ sessionId: `session-${index}`, workDir: `/repo/${index}` }) + ) + await writeFile(indexPath, `${lines.join('\n')}\n`) + + await expect(readKimiWorkDirForSessionId(indexPath, 'session-0')).resolves.toBe('/repo/0') + expect((await readKimiWorkDirBySessionId(indexPath)).size).toBe( + KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX + ) + }) + it('releases a retained map when its index file disappears', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-delete-')) tempDirs.push(root) diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.ts index fb5f6926eb3..6f7bc951753 100644 --- a/src/main/ai-vault/session-scanner-kimi-index-cache.ts +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.ts @@ -13,6 +13,12 @@ type KimiSessionIndexCacheEntry = { } export const KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS = 64 +export const KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX = 2_048 +export const KIMI_WORK_DIR_CACHE_KEY_MAX_UTF8_BYTES = 32 * 1024 +export const KIMI_WORK_DIR_SESSION_ID_MAX_UTF8_BYTES = 64 * 1024 +export const KIMI_WORK_DIR_VALUE_MAX_UTF8_BYTES = 128 * 1024 +export const KIMI_WORK_DIR_MAP_MAX_RETAINED_UTF8_BYTES = 512 * 1024 +const KIMI_WORK_DIR_ENTRY_OVERHEAD_BYTES = 64 // Active Vault scans refresh this window; closing the surface releases parsed // index maps soon without making a live Kimi session reread on every scan. export const KIMI_WORK_DIR_CACHE_TTL_MS = 5 * 60_000 @@ -52,8 +58,13 @@ export class KimiSessionIndexCache { generation: number, load: () => Promise> ): Promise> { - if (generation < this.minimumCacheGeneration) { - return load() + const loadBounded = (): Promise> => + load().then((value) => boundKimiWorkDirMap(value)) + if ( + generation < this.minimumCacheGeneration || + Buffer.byteLength(indexPath, 'utf8') > KIMI_WORK_DIR_CACHE_KEY_MAX_UTF8_BYTES + ) { + return loadBounded() } const cached = this.entries.get(indexPath) const now = Date.now() @@ -64,7 +75,7 @@ export class KimiSessionIndexCache { if (cached && cached.generation > generation) { // Why: a slower, older stat must not replace a newer file generation // that another concurrent scan already cached for the same path. - return load() + return loadBounded() } const entry: KimiSessionIndexCacheEntry = { @@ -72,7 +83,7 @@ export class KimiSessionIndexCache { generation, identity, timer: null, - value: load() + value: loadBounded() } this.remember(indexPath, entry, now) return entry.value @@ -120,6 +131,65 @@ export class KimiSessionIndexCache { } } +const retainedBytesByWorkDirMap = new WeakMap, number>() + +export function retainKimiWorkDir( + map: Map, + sessionId: string, + workDir: string +): void { + boundKimiWorkDirMap(map) + const sessionIdBytes = Buffer.byteLength(sessionId, 'utf8') + const workDirBytes = Buffer.byteLength(workDir, 'utf8') + if ( + sessionIdBytes > KIMI_WORK_DIR_SESSION_ID_MAX_UTF8_BYTES || + workDirBytes > KIMI_WORK_DIR_VALUE_MAX_UTF8_BYTES + ) { + return + } + let retainedBytes = retainedBytesByWorkDirMap.get(map) ?? 0 + const existing = map.get(sessionId) + if (existing !== undefined) { + retainedBytes -= kimiWorkDirEntryBytes(sessionId, existing) + } + map.delete(sessionId) + const entryBytes = sessionIdBytes + workDirBytes + KIMI_WORK_DIR_ENTRY_OVERHEAD_BYTES + while ( + map.size >= KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX || + retainedBytes + entryBytes > KIMI_WORK_DIR_MAP_MAX_RETAINED_UTF8_BYTES + ) { + const oldest = map.entries().next().value + if (!oldest) { + break + } + map.delete(oldest[0]) + retainedBytes -= kimiWorkDirEntryBytes(oldest[0], oldest[1]) + } + map.set(sessionId, workDir) + retainedBytesByWorkDirMap.set(map, retainedBytes + entryBytes) +} + +function boundKimiWorkDirMap(map: Map): Map { + if (retainedBytesByWorkDirMap.has(map)) { + return map + } + const entries = [...map] + map.clear() + retainedBytesByWorkDirMap.set(map, 0) + for (const [sessionId, workDir] of entries) { + retainKimiWorkDir(map, sessionId, workDir) + } + return map +} + +function kimiWorkDirEntryBytes(sessionId: string, workDir: string): number { + return ( + Buffer.byteLength(sessionId, 'utf8') + + Buffer.byteLength(workDir, 'utf8') + + KIMI_WORK_DIR_ENTRY_OVERHEAD_BYTES + ) +} + function identitiesMatch(left: KimiSessionIndexIdentity, right: KimiSessionIndexIdentity): boolean { return ( left.changeTimeMs === right.changeTimeMs && diff --git a/src/main/ai-vault/session-scanner-kimi-parser.test.ts b/src/main/ai-vault/session-scanner-kimi-parser.test.ts index 67748ae727f..a093e5d0ae5 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.test.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.test.ts @@ -209,6 +209,25 @@ describe('parseKimiSessionFile', () => { expect(assistantPreview.text.endsWith('...')).toBe(true) }) + it('bounds many streamed assistant chunks before the preview is flushed', async () => { + const wireLines = Array.from({ length: 600 }, () => ({ + type: 'context.append_loop_event', + event: { type: 'content.part', part: { type: 'text', text: 'x'.repeat(1024) } } + })) + wireLines.push({ + type: 'context.append_loop_event', + event: { type: 'step.end', part: { type: 'text', text: '' } } + }) + const { file } = await writeKimiSession({ wireLines }) + + const session = await parseKimiSessionFile(file, 'darwin') + + expect(session?.messageCount).toBe(1) + expect(session?.previewMessages).toEqual([ + { role: 'assistant', text: `${'x'.repeat(217)}...`, timestamp: null } + ]) + }) + it('falls back to lastPrompt when the title is empty', async () => { const { file } = await writeKimiSession({ state: { diff --git a/src/main/ai-vault/session-scanner-kimi-parser.ts b/src/main/ai-vault/session-scanner-kimi-parser.ts index 61341098e0d..7767d39f854 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.ts @@ -1,7 +1,6 @@ -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' -import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' +import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import { addPreviewContent, addPreviewMessage, @@ -13,7 +12,7 @@ import { kimiPrimaryAgentWirePath, kimiSessionIdFromStatePath, kimiSessionIndexPathFromStatePath, - readKimiWorkDirBySessionId + readKimiWorkDirForSessionId } from './session-scanner-kimi-paths' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { @@ -26,6 +25,15 @@ import { parseJsonObject } from './session-scanner-values' +// The rendered preview is 220 chars; this also covers hidden-context close-tag scanning. +const KIMI_ASSISTANT_PREVIEW_SOURCE_MAX_CHARS = 512 * 1024 +const KIMI_ASSISTANT_PREVIEW_CHUNK_MAX = 256 + +type KimiAssistantPreviewBuffer = { + chunks: string[] + charCount: number +} + // Parses a Kimi Code `state.json` plus its sibling `agents//wire.jsonl` // transcript into an AI Vault session. Metadata (title, timestamps, last prompt) // comes from state.json; the work directory comes from the top-level @@ -34,13 +42,30 @@ export async function parseKimiSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - let stateRecord: Record | null + let metadata: { + title: string | null + fallbackTitle: string | null + createdAt: string | null + updatedAt: string | null + wirePath: string + } | null try { - stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + metadata = await withAiVaultWholeJsonFile(file.path, (content) => { + const stateRecord = parseJsonObject(content) + return stateRecord + ? { + title: normalizeTitleText(extractString(stateRecord.title) ?? ''), + fallbackTitle: normalizeTitleText(extractString(stateRecord.lastPrompt) ?? ''), + createdAt: extractString(stateRecord.createdAt), + updatedAt: extractString(stateRecord.updatedAt), + wirePath: kimiPrimaryAgentWirePath(file.path, stateRecord) + } + : null + }) } catch { return null } - if (!stateRecord) { + if (!metadata) { return null } @@ -50,17 +75,17 @@ export async function parseKimiSessionFile( // Why: Kimi sessions are work-dir-scoped — the resume command must `cd` into // the original directory or the CLI rejects it. That path lives only in the // top-level session_index.jsonl, keyed by the (prefixed) session id. - const workDirBySessionId = await readKimiWorkDirBySessionId( - kimiSessionIndexPathFromStatePath(file.path) + accumulator.cwd = await readKimiWorkDirForSessionId( + kimiSessionIndexPathFromStatePath(file.path), + sessionId ) - accumulator.cwd = workDirBySessionId.get(sessionId) ?? null - accumulator.title = normalizeTitleText(extractString(stateRecord.title) ?? '') - accumulator.fallbackTitle = normalizeTitleText(extractString(stateRecord.lastPrompt) ?? '') - updateTimeline(accumulator, extractString(stateRecord.createdAt)) - updateTimeline(accumulator, extractString(stateRecord.updatedAt)) + accumulator.title = metadata.title + accumulator.fallbackTitle = metadata.fallbackTitle + updateTimeline(accumulator, metadata.createdAt) + updateTimeline(accumulator, metadata.updatedAt) - await consumeKimiWireTranscript(accumulator, kimiPrimaryAgentWirePath(file.path, stateRecord)) + await consumeKimiWireTranscript(accumulator, metadata.wirePath) return finalizeSession(accumulator, platform) } @@ -69,14 +94,15 @@ async function consumeKimiWireTranscript( accumulator: SessionAccumulator, wirePath: string ): Promise { - let pendingAssistantText: string[] = [] + const pendingAssistantText: KimiAssistantPreviewBuffer = { chunks: [], charCount: 0 } const flushAssistant = (): void => { // Why: previews use the 220-char limit (normalizePreviewText), not the // 96-char title limit — assistant replies are shown in full preview width // like every other agent's. Join raw chunks first so inter-chunk spacing // survives; normalizePreviewText then collapses whitespace and caps length. - const text = normalizePreviewText(pendingAssistantText.join('')) - pendingAssistantText = [] + const text = normalizePreviewText(pendingAssistantText.chunks.join('')) + pendingAssistantText.chunks = [] + pendingAssistantText.charCount = 0 if (text) { accumulator.messageCount++ addPreviewMessage(accumulator, { role: 'assistant', text }) @@ -84,10 +110,7 @@ async function consumeKimiWireTranscript( } try { - const lines = createInterface({ - input: createReadStream(wirePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(wirePath) for await (const line of lines) { const record = parseJsonObject(line) if (!record) { @@ -134,7 +157,7 @@ function consumeKimiUserMessage(accumulator: SessionAccumulator, value: unknown) function consumeKimiLoopEvent( value: unknown, - pendingAssistantText: string[], + pendingAssistantText: KimiAssistantPreviewBuffer, flushAssistant: () => void ): void { const event = asRecord(value) @@ -146,7 +169,7 @@ function consumeKimiLoopEvent( // Push the raw chunk text; flushAssistant normalizes the joined result so // multi-chunk spacing is not lost to per-chunk trimming. if (part?.type === 'text' && typeof part.text === 'string') { - pendingAssistantText.push(part.text) + appendKimiAssistantPreviewText(pendingAssistantText, part.text) } return } @@ -157,6 +180,19 @@ function consumeKimiLoopEvent( } } +function appendKimiAssistantPreviewText(buffer: KimiAssistantPreviewBuffer, text: string): void { + const remaining = KIMI_ASSISTANT_PREVIEW_SOURCE_MAX_CHARS - buffer.charCount + if (remaining <= 0 || text.length === 0) { + return + } + const retained = text.length <= remaining ? text : text.slice(0, remaining) + buffer.chunks.push(retained) + buffer.charCount += retained.length + if (buffer.chunks.length >= KIMI_ASSISTANT_PREVIEW_CHUNK_MAX) { + buffer.chunks = [buffer.chunks.join('')] + } +} + // Kimi reports per-turn usage as {inputOther, output, inputCacheRead, // inputCacheCreation}; sum all four for a session total. Skip any future // cumulative ("session"-scoped) record so turn deltas are not double-counted. diff --git a/src/main/ai-vault/session-scanner-kimi-paths.ts b/src/main/ai-vault/session-scanner-kimi-paths.ts index 7ed457f6c07..2c18b51ecf8 100644 --- a/src/main/ai-vault/session-scanner-kimi-paths.ts +++ b/src/main/ai-vault/session-scanner-kimi-paths.ts @@ -1,16 +1,21 @@ import { stat } from 'node:fs/promises' -import { createReadStream } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' -import { createInterface } from 'node:readline' -import { asRecord, extractString } from './session-scanner-values' +import { asRecord, extractString, parseJsonObject } from './session-scanner-values' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' import { KimiSessionIndexCache, KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, - KIMI_WORK_DIR_CACHE_TTL_MS + KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX, + KIMI_WORK_DIR_CACHE_TTL_MS, + retainKimiWorkDir } from './session-scanner-kimi-index-cache' -export { KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, KIMI_WORK_DIR_CACHE_TTL_MS } +export { + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX, + KIMI_WORK_DIR_CACHE_TTL_MS +} // Why: Kimi Code stores sessions under /sessions/, mirroring the // CLI's own `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (see kimi-fetcher.ts). @@ -99,31 +104,34 @@ export async function readKimiWorkDirBySessionId(indexPath: string): Promise { + const workDirs = await readKimiWorkDirBySessionId(indexPath) + const cached = workDirs.get(sessionId) + if (cached) { + return cached + } + const workDir = await readKimiWorkDirFromIndex(indexPath, sessionId) + if (workDir) { + retainKimiWorkDir(workDirs, sessionId, workDir) + } + return workDir +} + async function parseKimiSessionIndex(indexPath: string): Promise> { const map = new Map() // Why: never reject. This promise is memoized and shared by every session // under one Kimi home; a mid-read failure (file deleted after stat, EACCES) // must degrade to whatever was parsed so the other sessions still list. try { - const lines = createInterface({ - input: createReadStream(indexPath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(indexPath) for await (const line of lines) { - if (!line.trim()) { - continue - } - let record: Record | null - try { - record = asRecord(JSON.parse(line) as unknown) - } catch { - continue - } - const sessionId = extractString(record?.sessionId) - const workDir = extractString(record?.workDir) - if (sessionId && workDir) { + const entry = parseKimiSessionIndexEntry(line) + if (entry) { // Later lines win so a resumed session reflects its most recent workDir. - map.set(sessionId, workDir) + retainKimiWorkDir(map, entry.sessionId, entry.workDir) } } } catch { @@ -131,3 +139,35 @@ async function parseKimiSessionIndex(indexPath: string): Promise { + let requestedWorkDir: string | null = null + try { + for await (const line of iterateAiVaultJsonlLines(indexPath)) { + const entry = parseKimiSessionIndexEntry(line) + if (entry?.sessionId === requestedSessionId) { + requestedWorkDir = entry.workDir + } + } + } catch { + // Match the partial, best-effort full-index read. + } + return requestedWorkDir +} + +function parseKimiSessionIndexEntry(line: string): { sessionId: string; workDir: string } | null { + if (!line.trim()) { + return null + } + try { + const record = parseJsonObject(line) + const sessionId = extractString(record?.sessionId) + const workDir = extractString(record?.workDir) + return sessionId && workDir ? { sessionId, workDir } : null + } catch { + return null + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index e4934aa8fda..b8f14a63af7 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -1,7 +1,10 @@ -import { readdir } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + listOpenCodeDatabaseFiles, + OPENCODE_DATABASE_FILE_LIMIT +} from '../opencode/opencode-database-files' import { listOpenCodeDatabases } from '../opencode-usage/scanner' import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' @@ -10,7 +13,6 @@ const OPENCODE_STORAGE_DIR = join( process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), 'storage' ) - export function opencodeDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], @@ -21,7 +23,7 @@ export function opencodeDiscoveries( return storageDirs.map(async (storageDir, index) => discoverOpenCodeSessions({ storageDir, - dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index), + dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues), limitPerAgent: limit, issues }) @@ -42,32 +44,36 @@ async function opencodeDbPathsForSource( options: AiVaultScanOptions, wslHomeDirs: readonly string[], storageDir: string, - sourceIndex: number + sourceIndex: number, + issues: AiVaultScanIssue[] ): Promise { if (options.opencodeDbPaths) { return sourceIndex === 0 ? options.opencodeDbPaths : [] } // Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir. if (sourceIndex === 0 && options.opencodeStorageDir) { - return listOpenCodeDatabasesInDirectory(dirname(storageDir)) + return listOpenCodeDatabasesInDirectory(dirname(storageDir), issues) } if (sourceIndex === 0) { return listOpenCodeDatabases() } const wslHomeDir = wslHomeDirs[sourceIndex - 1] return wslHomeDir - ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode')) + ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'), issues) : [] } -async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise { - try { - const entries = await readdir(dataDir, { withFileTypes: true }) - return entries - .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) - .map((entry) => join(dataDir, entry.name)) - .sort() - } catch { - return [] +async function listOpenCodeDatabasesInDirectory( + dataDir: string, + issues: AiVaultScanIssue[] +): Promise { + const result = await listOpenCodeDatabaseFiles(dataDir) + if (result.truncated) { + issues.push({ + agent: 'opencode', + path: dataDir, + message: `OpenCode database discovery stopped after ${OPENCODE_DATABASE_FILE_LIMIT} files.` + }) } + return result.paths } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts index 7f50a220247..dd075baa36e 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts @@ -1,12 +1,18 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import Database from '../sqlite/sync-database' import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import { + OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES, + OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES, + OPENCODE_SQLITE_PART_JSON_MAX_BYTES, + OPENCODE_SQLITE_SESSION_ID_MAX_BYTES +} from './session-scanner-opencode-sqlite-limits' // Part B (#8864) bounds: discovery reads only the newest session identities and // recency fields, while the preview join reads parts of only the newest 100 @@ -15,6 +21,7 @@ import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' let tempDirs: string[] = [] afterEach(() => { + vi.restoreAllMocks() for (const dir of tempDirs) { rmSync(dir, { recursive: true, force: true }) } @@ -120,6 +127,22 @@ function insertMessageWithPart( } describe('listOpenCodeSqliteSessions — LIMIT-first discovery', () => { + it('keeps an exact-limit session id and omits limit +1 before materializing it', async () => { + const { db, path } = createTempDb() + applySchema(db) + const exactId = 'e'.repeat(OPENCODE_SQLITE_SESSION_ID_MAX_BYTES) + const oversizedId = 'o'.repeat(OPENCODE_SQLITE_SESSION_ID_MAX_BYTES + 1) + insertSession(db, exactId, 1_777_634_001_000) + insertSession(db, oversizedId, 1_777_634_002_000) + db.close() + + const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues: [] }) + + expect(candidates.map((candidate) => candidate.file.path)).toEqual([ + buildOpenCodeSqliteCandidatePath(path, exactId) + ]) + }) + it('returns only the newest `limit` sessions by time_updated', async () => { const { db, path } = createTempDb() applySchema(db) @@ -174,6 +197,88 @@ describe('listOpenCodeSqliteSessions — LIMIT-first discovery', () => { }) describe('parseOpenCodeSqliteSession — bounded preview window', () => { + it('preserves exact-limit model JSON and drops limit +1 before JSON.parse', async () => { + const { db, path } = createTempDb() + applySchema(db) + insertSession(db, 'ses_model_exact', 1_777_634_001_000) + insertSession(db, 'ses_model_oversized', 1_777_634_002_000) + const modelPrefix = '{"id":"' + const modelSuffix = '"}' + db.prepare(`UPDATE session SET model = ? WHERE id = 'ses_model_exact'`).run( + `${modelPrefix}${'m'.repeat( + OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES - modelPrefix.length - modelSuffix.length + )}${modelSuffix}` + ) + db.prepare(`UPDATE session SET model = ? WHERE id = 'ses_model_oversized'`).run( + 'x'.repeat(OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES + 1) + ) + db.close() + + const exact = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_model_exact', + platform: 'darwin' + }) + const parseSpy = vi.spyOn(JSON, 'parse') + const oversized = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_model_oversized', + platform: 'darwin' + }) + + expect(exact?.model).toHaveLength( + OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES - modelPrefix.length - modelSuffix.length + ) + expect(oversized?.model).toBeNull() + expect(parseSpy).not.toHaveBeenCalled() + }) + + it('omits oversized message and part JSON before retaining previews', async () => { + const { db, path } = createTempDb() + applySchema(db) + insertSession(db, 'ses_oversized_rows', 1_777_634_001_000) + db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`).run( + 'msg_oversized', + 'ses_oversized_rows', + 1_777_634_000_100, + 'x'.repeat(OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES + 1) + ) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)` + ).run( + 'part_for_oversized_message', + 'msg_oversized', + 'ses_oversized_rows', + 1_777_634_000_100, + JSON.stringify({ type: 'text', text: 'must not surface' }) + ) + db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`).run( + 'msg_normal', + 'ses_oversized_rows', + 1_777_634_000_200, + JSON.stringify({ role: 'user' }) + ) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)` + ).run( + 'part_oversized', + 'msg_normal', + 'ses_oversized_rows', + 1_777_634_000_200, + 'x'.repeat(OPENCODE_SQLITE_PART_JSON_MAX_BYTES + 1) + ) + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_oversized_rows', + platform: 'darwin' + }) + + expect(session?.messageCount).toBe(1) + expect(session?.previewMessages).toEqual([]) + }) + it('surfaces the newest text previews and retains the full message count', async () => { // Positive coverage of preview selection (newest OPENCODE_SQLITE_PREVIEW_LIMIT // by recency) + full-count retention. The window bound itself is pinned by the diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-limits.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-limits.ts new file mode 100644 index 00000000000..b0cd7c0f360 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-limits.ts @@ -0,0 +1,5 @@ +export const OPENCODE_SQLITE_SESSION_ID_MAX_BYTES = 16 * 1024 +export const OPENCODE_SQLITE_SESSION_TEXT_MAX_BYTES = 1024 * 1024 +export const OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES = 1024 * 1024 +export const OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES = 1024 * 1024 +export const OPENCODE_SQLITE_PART_JSON_MAX_BYTES = 4 * 1024 * 1024 diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index b89b97b229e..a398712bf47 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -5,6 +5,7 @@ import type { SessionFileCandidate } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import { OPENCODE_SQLITE_SESSION_ID_MAX_BYTES } from './session-scanner-opencode-sqlite-limits' // Why: the SQLite session-list query + reader lives in its own electron-free // module so both the worker entry and the main-thread worker client can import @@ -41,7 +42,9 @@ function buildSessionListQuery(db: SyncDatabase): string { // refresh; the parse path loads metadata only for candidates it actually uses. return `SELECT id, time_created, time_updated FROM session - WHERE 1=1 ${parentIdPredicate} ${archivedPredicate} + WHERE typeof(id) = 'text' + AND length(CAST(id AS BLOB)) <= ${OPENCODE_SQLITE_SESSION_ID_MAX_BYTES} + ${parentIdPredicate} ${archivedPredicate} ORDER BY CASE WHEN time_updated > 0 THEN time_updated ELSE time_created END DESC LIMIT ?` } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts index ab04a455cb5..ee68f50a7ff 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts @@ -4,6 +4,7 @@ import { IDLE_TEARDOWN_MS, LIST_TIMEOUT_MS, MAX_CONSECUTIVE_DEATHS, + MAX_PENDING_CALLS, OpenCodeSqliteWorkerClient, PARSE_TIMEOUT_MS } from './session-scanner-opencode-sqlite-worker-client' @@ -122,6 +123,33 @@ describe('OpenCodeSqliteWorkerClient', () => { expect(workers).toHaveLength(1) }) + it('rejects overload beyond the bounded process-wide queue and recovers after draining', async () => { + const workers: FakeWorker[] = [] + const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} }) + const admitted = Array.from({ length: MAX_PENDING_CALLS }, (_, index) => + client.parse({ dbPath: `/db#${index}`, sessionId: `s${index}`, platform: 'darwin' }) + ) + const settled = admitted.map((promise) => promise.catch((error: unknown) => error)) + + await expect( + client.parse({ dbPath: '/db#overflow', sessionId: 'overflow', platform: 'darwin' }) + ).rejects.toThrow(/256-request capacity/) + expect(workers[0]!.postedRequests).toHaveLength(1) + + for (let index = 0; index < MAX_CONSECUTIVE_DEATHS; index += 1) { + workers.at(-1)!.emit('error', new Error(`drain ${index}`)) + } + await Promise.all(settled) + + const recovered = client.parse({ + dbPath: '/db#recovered', + sessionId: 'recovered', + platform: 'darwin' + }) + workers.at(-1)!.emit('message', { id: workers.at(-1)!.lastId(), ok: true, value: 'ok' }) + await expect(recovered).resolves.toBe('ok') + }) + it('times out only the active call, then respawns and drains the queue', async () => { vi.useFakeTimers() try { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index ef156b0b3f4..faa4ef77e97 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -19,6 +19,7 @@ import { errorMessage } from './session-scanner-values' export const LIST_TIMEOUT_MS = 30_000 export const PARSE_TIMEOUT_MS = 15_000 export const IDLE_TEARDOWN_MS = 30_000 +export const MAX_PENDING_CALLS = 256 // After this many consecutive worker deaths, fail the remaining queued calls to // scan issues instead of respawning so a DB that reliably kills the worker can't // spin a crash loop. Reset on any successful response, after draining, and when a @@ -142,6 +143,11 @@ export class OpenCodeSqliteWorkerClient { } private dispatch(request: OpenCodeSqliteRequestBody, timeoutMs: number): Promise { + if (this.queue.length + (this.active ? 1 : 0) >= MAX_PENDING_CALLS) { + return Promise.reject( + new Error(`OpenCode SQLite worker reached its ${MAX_PENDING_CALLS}-request capacity`) + ) + } return new Promise((resolve, reject) => { const id = this.nextId++ // A fresh burst from full idle starts a new scan: clear any death count diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index ad3e9b76f41..898ca9624db 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -5,9 +5,15 @@ import { finalizeSession, updateTimeline } from './session-scanner-accumulator' -import { normalizeTitleText } from './session-scanner-values' +import { normalizeTitleText, parseAiVaultJsonText } from './session-scanner-values' import SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import { + OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES, + OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES, + OPENCODE_SQLITE_PART_JSON_MAX_BYTES, + OPENCODE_SQLITE_SESSION_TEXT_MAX_BYTES +} from './session-scanner-opencode-sqlite-limits' // Why: OpenCode 1.17.x migrated session storage from per-session JSON files // to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module @@ -63,8 +69,16 @@ function canReadOpenCodeSessions(db: SyncDatabase): boolean { ) } -function sessionColumnSelect(db: SyncDatabase, columnName: string): string { - return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' +function boundedSessionTextColumnSelect( + db: SyncDatabase, + columnName: string, + maxBytes: number +): string { + return columnExists(db, 'session', columnName) + ? `CASE WHEN typeof(s.${columnName}) = 'text' + AND length(CAST(s.${columnName} AS BLOB)) <= ${maxBytes} + THEN s.${columnName} ELSE NULL END` + : 'NULL' } function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string { @@ -83,15 +97,20 @@ function buildSessionQuery(db: SyncDatabase): string { const messageCountSubquery = canCountOpenCodeMessages(db) ? `(SELECT COUNT(*) FROM message m WHERE m.session_id = s.id - AND json_extract(m.data, '$.role') IN ('user','assistant'))` + AND CASE + WHEN typeof(m.data) = 'text' + AND length(CAST(m.data AS BLOB)) <= ${OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES} + THEN json_extract(m.data, '$.role') IN ('user','assistant') + ELSE 0 + END)` : '0' return `SELECT s.id, - ${sessionColumnSelect(db, 'title')} AS title, - ${sessionColumnSelect(db, 'directory')} AS directory, + ${boundedSessionTextColumnSelect(db, 'title', OPENCODE_SQLITE_SESSION_TEXT_MAX_BYTES)} AS title, + ${boundedSessionTextColumnSelect(db, 'directory', OPENCODE_SQLITE_SESSION_TEXT_MAX_BYTES)} AS directory, s.time_created, s.time_updated, - ${sessionColumnSelect(db, 'model')} AS model_json, - ${sessionColumnSelect(db, 'agent')} AS agent, + ${boundedSessionTextColumnSelect(db, 'model', OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES)} AS model_json, + ${boundedSessionTextColumnSelect(db, 'agent', OPENCODE_SQLITE_SESSION_TEXT_MAX_BYTES)} AS agent, ${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input, ${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output, ${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning, @@ -108,7 +127,7 @@ function extractModelId(modelJson: string | null): string | null { return null } try { - const parsed = JSON.parse(modelJson) as unknown + const parsed = parseAiVaultJsonText(modelJson) const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) @@ -137,7 +156,7 @@ function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role function extractPartText(partData: string): string | null { try { - const parsed = JSON.parse(partData) as unknown + const parsed = parseAiVaultJsonText(partData) const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) @@ -172,11 +191,18 @@ function buildPreviewQuery(db: SyncDatabase): string | null { json_extract(m.data, '$.summary.body') AS summary_body FROM (SELECT id, data FROM message WHERE session_id = ? + AND typeof(data) = 'text' + AND length(CAST(data AS BLOB)) <= ${OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES} ORDER BY time_created DESC, id DESC LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m JOIN part p ON p.message_id = m.id WHERE json_extract(m.data, '$.role') IN ('user','assistant') - AND json_extract(p.data, '$.type') = 'text' + AND CASE + WHEN typeof(p.data) = 'text' + AND length(CAST(p.data AS BLOB)) <= ${OPENCODE_SQLITE_PART_JSON_MAX_BYTES} + THEN json_extract(p.data, '$.type') = 'text' + ELSE 0 + END ORDER BY p.time_created DESC LIMIT ?` } diff --git a/src/main/ai-vault/session-scanner-parse-cache.test.ts b/src/main/ai-vault/session-scanner-parse-cache.test.ts index 6ba5e3266d6..1d1bbf58231 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.test.ts @@ -7,6 +7,7 @@ import { resetSessionParseCacheForTests, createSessionParseStats } from './session-scanner-parse-cache' +import { AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES } from './session-parse-cache-retention' import { parseClaudeSessionFile } from './session-scanner-primary-parsers' import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' @@ -92,6 +93,29 @@ describe('parseAgentSessionFileCached', () => { expect(stats.incremental).toBe(0) }) + it('returns oversized metadata unchanged without retaining it', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + const oversizedModel = 'é'.repeat(AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES / 2) + await writeFile( + path, + `${userRecord(0, 'question')}\n${assistantRecord(1, 'answer').replace( + 'claude-fable-5', + oversizedModel + )}\n` + ) + const candidate = await claudeCandidate(path) + const stats = createSessionParseStats() + + const first = await parseAgentSessionFileCached(candidate, process.platform, stats) + const second = await parseAgentSessionFileCached(candidate, process.platform, stats) + + expect(first?.model).toBe(oversizedModel) + expect(second).toEqual(first) + expect(stats.fullParses).toBe(2) + expect(stats.reused).toBe(0) + }) + it('incrementally parses appended lines and matches a cold parse exactly', async () => { const root = await makeTempDir() const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index b7aab770432..64835da7b5e 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,4 +1,3 @@ -import { createReadStream } from 'node:fs' import { open } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' @@ -14,28 +13,21 @@ import { } from './session-scanner-secondary-parsers' import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' +import { consumeAiVaultJsonlLines } from './session-jsonl-line-reader' +import { + getSessionParseCacheEntry, + resetSessionParseCacheRetentionForTests, + storeSessionParseCacheEntry, + type SessionParseCacheEntry +} from './session-parse-cache-retention' -// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a -// full steady-state result set stays resident between forced rescans. -const MAX_CACHE_ENTRIES = 4096 +export { + seedSessionParseCache, + snapshotSessionParseCacheForPersistence, + type PersistedSessionParseCacheEntry +} from './session-parse-cache-retention' const NEWLINE_BYTE = 0x0a -const CARRIAGE_RETURN_BYTE = 0x0d - -type ResumePoint = { - state: ResumableSessionParseState - // Byte offset just past the last complete ('\n'-terminated) line consumed; - // a trailing unterminated line is deliberately left before this point. - byteOffset: number -} - -type SessionParseCacheEntry = { - mtimeMs: number - sizeBytes: number | null - platform: NodeJS.Platform - session: AiVaultSession | null - resume: ResumePoint | null -} // Incremental append-parsing applies only to transcripts that are append-only // JSONL line-folds. Whole-JSON documents (grok/rovo/devin/hermes/gemini-json) @@ -91,67 +83,8 @@ export function createSessionParseStats(): SessionParseStats { return { reused: 0, incremental: 0, fullParses: 0, bytesRead: 0 } } -const cache = new Map() - export function resetSessionParseCacheForTests(): void { - cache.clear() -} - -// Persisted subset of a cache entry: the non-serializable `resume` parser -// state is dropped (see session-parse-cache-persistence.ts). -export type PersistedSessionParseCacheEntry = Omit - -export function snapshotSessionParseCacheForPersistence(): [ - string, - PersistedSessionParseCacheEntry -][] { - return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [ - path, - { - mtimeMs: entry.mtimeMs, - sizeBytes: entry.sizeBytes, - platform: entry.platform, - session: entry.session - } - ]) -} - -// Seeded entries carry `resume: null`: after a restart an unchanged file is a -// cache hit; a file that changed while the app was closed pays one full -// (not incremental) re-parse. -export function seedSessionParseCache( - entries: Iterable<[string, PersistedSessionParseCacheEntry]> -): void { - const list = [...entries] - // Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest - // tail rather than seeding the oldest entries and dropping the tail. - for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) { - if (cache.size >= MAX_CACHE_ENTRIES) { - return - } - // In-process entries are always fresher than persisted ones; never clobber. - if (cache.has(path)) { - continue - } - cache.set(path, { - mtimeMs: entry.mtimeMs, - sizeBytes: entry.sizeBytes, - platform: entry.platform, - session: entry.session, - resume: null - }) - } -} - -function storeEntry(path: string, entry: SessionParseCacheEntry): void { - cache.delete(path) - cache.set(path, entry) - if (cache.size > MAX_CACHE_ENTRIES) { - const oldest = cache.keys().next() - if (!oldest.done) { - cache.delete(oldest.value) - } - } + resetSessionParseCacheRetentionForTests() } /** @@ -169,7 +102,7 @@ export async function parseAgentSessionFileCached( stats?: SessionParseStats ): Promise { const { file } = candidate - const entry = cache.get(file.path) + const entry = getSessionParseCacheEntry(file.path) const unchanged = entry !== undefined && @@ -190,7 +123,7 @@ export async function parseAgentSessionFileCached( entry.session = { ...entry.session, subagentTranscriptCount } } } - storeEntry(file.path, entry) + storeSessionParseCacheEntry(file.path, entry) return entry.session } @@ -203,7 +136,7 @@ export async function parseAgentSessionFileCached( stats, stateFactory }) - storeEntry(file.path, parsed) + storeSessionParseCacheEntry(file.path, parsed) return parsed.session } @@ -212,7 +145,7 @@ export async function parseAgentSessionFileCached( stats.bytesRead += file.sizeBytes ?? 0 } const session = await parseAgentSessionFile(candidate, platform) - storeEntry(file.path, { + storeSessionParseCacheEntry(file.path, { mtimeMs: file.mtimeMs, sizeBytes: file.sizeBytes ?? null, platform, @@ -250,7 +183,7 @@ async function parseResumableCandidate(args: { } } - const readResult = await consumeCompleteJsonlLines({ + const readResult = await consumeAiVaultJsonlLines({ path: file.path, start: startOffset, onLine: (line) => state.consumeLine(line) @@ -294,48 +227,3 @@ async function endsWithNewlineAt(path: string, offset: number): Promise await handle.close() } } - -type JsonlReadResult = { - consumedThrough: number - trailingPartialLine: string | null - bytesRead: number -} - -// Byte-accurate replacement for readline: offsets must count bytes (not -// UTF-8-decoded characters) so a resumed read starts exactly where the last -// complete line ended. -async function consumeCompleteJsonlLines(args: { - path: string - start: number - onLine: (line: string) => void -}): Promise { - let consumedThrough = args.start - let bytesRead = 0 - let remainder: Buffer | null = null - - const stream = createReadStream(args.path, { start: args.start }) - for await (const chunk of stream as AsyncIterable) { - bytesRead += chunk.length - const data = remainder ? Buffer.concat([remainder, chunk]) : chunk - let lineStart = 0 - let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) - while (newlineIndex !== -1) { - let lineEnd = newlineIndex - if (lineEnd > lineStart && data[lineEnd - 1] === CARRIAGE_RETURN_BYTE) { - lineEnd-- - } - args.onLine(data.toString('utf-8', lineStart, lineEnd)) - lineStart = newlineIndex + 1 - newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) - } - consumedThrough += lineStart - // Copy the tail so retaining it doesn't pin the whole chunk buffer. - remainder = lineStart < data.length ? Buffer.from(data.subarray(lineStart)) : null - } - - return { - consumedThrough, - trailingPartialLine: remainder && remainder.length > 0 ? remainder.toString('utf-8') : null, - bytesRead - } -} diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index c7e67ec463b..cbd36f74c36 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -1,5 +1,3 @@ -import { createReadStream } from 'node:fs' -import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' @@ -13,11 +11,13 @@ import { addPreviewContent, createAccumulator, finalizeSession, + sessionAccumulatorRetainedUtf8Bytes, sessionIdFromFileName, updateLatestLocation, updateTimeline } from './session-scanner-accumulator' import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' import { asRecord, claudeUsageTotal, @@ -193,6 +193,11 @@ function claudeResumeStateFromParseState( return { consumeLine: (line) => consumeClaudeSessionLine(state, line), clone: () => claudeResumeStateFromParseState(cloneClaudeSessionParseState(state)), + retainedUtf8Bytes: () => + sessionAccumulatorRetainedUtf8Bytes(state.accumulator) + + Buffer.byteLength(state.metaTitle ?? '', 'utf8') + + Buffer.byteLength(state.generatedTitle ?? '', 'utf8') + + Buffer.byteLength(state.firstUserTitle ?? '', 'utf8'), touchFile: (file) => { state.accumulator.modifiedAt = file.modifiedAt }, @@ -204,10 +209,7 @@ export async function parseClaudeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(file.path) return parseClaudeSessionLines({ file, lines, platform }) } diff --git a/src/main/ai-vault/session-scanner-retention.test.ts b/src/main/ai-vault/session-scanner-retention.test.ts new file mode 100644 index 00000000000..4c3f63dfe00 --- /dev/null +++ b/src/main/ai-vault/session-scanner-retention.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { AI_VAULT_SESSION_ID_MAX_UTF8_BYTES } from './session-list-retention' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures' + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('local AI Vault result retention', () => { + it('omits a resume-critical overflow and surfaces a bounded scan issue', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-vault-result-retention-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'x'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES + 1) + await writeJsonlFile(join(roots.claudeProjectsDir, 'repo', 'session.jsonl'), [ + { + type: 'user', + sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + cwd: '/repo', + message: { role: 'user', content: 'Retain safely' } + } + ]) + + const result = await scanAiVaultSessions(roots) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ + path: join(roots.claudeProjectsDir, 'repo', 'session.jsonl'), + message: expect.stringContaining('session id exceeds the 65536 byte limit') + }) + ]) + }) +}) diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index 41aac5f2700..525ef6d4c9c 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -1,6 +1,4 @@ -import { createReadStream } from 'node:fs' -import { readdir, stat } from 'node:fs/promises' -import { createInterface } from 'node:readline' +import { opendir, stat } from 'node:fs/promises' import { extname, join } from 'node:path' import { isPathInsideOrEqual, @@ -8,6 +6,8 @@ import { } from '../../shared/cross-platform-path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { parseWslUncPath } from '../../shared/wsl-paths' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' +import { AiVaultScopeCwdCache } from './session-scope-cwd-cache' import type { FileWithMtime } from './session-scanner-types' import { errorMessage, extractString, parseJsonObject } from './session-scanner-values' @@ -19,8 +19,7 @@ const CLAUDE_EXTENSIONS = new Set(['.jsonl']) // A Claude project dir encodes exactly one cwd, so a resolved cwd never // changes; caching it spares each rescan the transcript-head reads. -const PROJECT_DIR_CWD_CACHE_MAX = 2048 -const projectDirCwdCache = new Map() +const projectDirCwdCache = new AiVaultScopeCwdCache() export function resetProjectDirCwdCacheForTests(): void { projectDirCwdCache.clear() @@ -29,19 +28,10 @@ export function resetProjectDirCwdCacheForTests(): void { async function cachedProjectDirCwd(projectDir: string): Promise { const cached = projectDirCwdCache.get(projectDir) if (cached !== undefined) { - // Refresh recency so hot in-scope dirs outlive one-off ones at the cap. - projectDirCwdCache.delete(projectDir) - projectDirCwdCache.set(projectDir, cached) return cached } const cwd = await readProjectDirCwd(projectDir) if (cwd) { - if (projectDirCwdCache.size >= PROJECT_DIR_CWD_CACHE_MAX) { - const oldest = projectDirCwdCache.keys().next() - if (!oldest.done) { - projectDirCwdCache.delete(oldest.value) - } - } projectDirCwdCache.set(projectDir, cwd) } return cwd @@ -70,7 +60,7 @@ export async function discoverInScopeClaudeFiles(args: { const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths) const collected = new Map() for (const rootDir of args.rootDirs) { - for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) { + for await (const projectDir of iterateProjectDirs(rootDir, scopeProjectPrefixes)) { const cwd = await cachedProjectDirCwd(projectDir) if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) { continue @@ -130,21 +120,20 @@ function isCwdInsideScopePath(scopePath: string, cwd: string): boolean { return isPathInsideOrEqual(wslScopePath.linuxPath, cwd) } -async function listProjectDirs( +async function* iterateProjectDirs( rootDir: string, scopeProjectPrefixes: ReadonlySet -): Promise { - let entries +): AsyncGenerator { try { - entries = await readdir(rootDir, { withFileTypes: true }) + const directory = await opendir(rootDir) + for await (const entry of directory) { + if (entry.isDirectory() && isClaudeProjectDirInScope(entry.name, scopeProjectPrefixes)) { + yield join(rootDir, entry.name) + } + } } catch { - return [] + // Missing or unreadable roots have no project directories to yield. } - return entries - .filter( - (entry) => entry.isDirectory() && isClaudeProjectDirInScope(entry.name, scopeProjectPrefixes) - ) - .map((entry) => join(rootDir, entry.name)) } async function readProjectDirCwd(projectDir: string): Promise { @@ -159,34 +148,31 @@ async function readProjectDirCwd(projectDir: string): Promise { } async function newestClaudeFilesInDir(projectDir: string): Promise { - let entries + const newest: { path: string; mtimeMs: number }[] = [] try { - entries = await readdir(projectDir, { withFileTypes: true }) + const directory = await opendir(projectDir) + for await (const entry of directory) { + if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) { + continue + } + const path = join(projectDir, entry.name) + try { + addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, { + path, + mtimeMs: (await stat(path)).mtimeMs + }) + } catch { + // Best effort: unreadable candidates are ignored here and reported during full collection. + } + } } catch { return [] } - const newest: { path: string; mtimeMs: number }[] = [] - for (const entry of entries) { - if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) { - continue - } - const path = join(projectDir, entry.name) - try { - addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, { - path, - mtimeMs: (await stat(path)).mtimeMs - }) - } catch { - // Best effort: unreadable candidates are ignored here and reported during - // full collection if the project directory proves in-scope. - } - } return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path) } async function readFirstCwd(filePath: string): Promise { - const input = createReadStream(filePath, { encoding: 'utf-8' }) - const lines = createInterface({ input, crlfDelay: Infinity }) + const lines = iterateAiVaultJsonlLines(filePath) let read = 0 try { for await (const line of lines) { @@ -200,11 +186,6 @@ async function readFirstCwd(filePath: string): Promise { } } catch { return null - } finally { - // readline.close() leaves the underlying stream open; destroy it so the early - // break/catch paths don't leak a file descriptor (this runs per project dir). - lines.close() - input.destroy() } return null } @@ -216,31 +197,30 @@ async function collectClaudeFiles(args: { limit: number excludedFilePaths: ReadonlySet }): Promise { - let entries try { - entries = await readdir(args.projectDir, { withFileTypes: true }) + const directory = await opendir(args.projectDir) + for await (const entry of directory) { + if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) { + continue + } + const path = join(args.projectDir, entry.name) + if (args.collected.has(path) || args.excludedFilePaths.has(path)) { + continue + } + try { + const fileStat = await stat(path) + addBoundedFile(args.collected, args.limit, { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + }) + } catch (err) { + args.issues.push({ agent: 'claude', path, message: errorMessage(err) }) + } + } } catch { - return - } - for (const entry of entries) { - if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) { - continue - } - const path = join(args.projectDir, entry.name) - if (args.collected.has(path) || args.excludedFilePaths.has(path)) { - continue - } - try { - const fileStat = await stat(path) - addBoundedFile(args.collected, args.limit, { - path, - mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString(), - sizeBytes: fileStat.size - }) - } catch (err) { - args.issues.push({ agent: 'claude', path, message: errorMessage(err) }) - } + // Missing or unreadable project directories contribute no sessions. } } diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index 364c25b5d85..b7c86b0adef 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -1,12 +1,12 @@ -import { createReadStream } from 'node:fs' -import { readFile, readdir } from 'node:fs/promises' +import { opendir } from 'node:fs/promises' import { join } from 'node:path' -import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { ExecutionHostId } from '../../shared/execution-host' +import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' +import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import type { FileWithMtime, ResumableSessionParseState, + ResumableParseFinalizeOptions, SessionAccumulator } from './session-scanner-types' import { @@ -35,19 +35,11 @@ import { tokenTotal } from './session-scanner-values' -type ParserSessionOptions = { - executionHostId?: ExecutionHostId - executionHostPlatform?: NodeJS.Platform | null -} - export async function parseCopilotSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(file.path) return parseCopilotSessionLines({ file, lines, platform }) } @@ -55,7 +47,7 @@ export async function parseCopilotSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ResumableParseFinalizeOptions = {} ): Promise { return parseCopilotSessionLines({ file, @@ -127,7 +119,7 @@ async function parseCopilotSessionLines(args: { file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform - options?: ParserSessionOptions + options?: ResumableParseFinalizeOptions }): Promise { const state = createCopilotSessionResumeState(args.file) for await (const line of args.lines) { @@ -140,10 +132,7 @@ export async function parseCursorSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) + const lines = iterateAiVaultJsonlLines(file.path) return parseCursorSessionLines({ file, lines, platform }) } @@ -151,7 +140,7 @@ export async function parseCursorSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ResumableParseFinalizeOptions = {} ): Promise { return parseCursorSessionLines({ file, @@ -193,7 +182,7 @@ async function parseCursorSessionLines(args: { file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform - options?: ParserSessionOptions + options?: ResumableParseFinalizeOptions }): Promise { const state = createCursorSessionResumeState(args.file) for await (const line of args.lines) { @@ -206,18 +195,28 @@ export async function parseOpenCodeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) - if (!record) { + const parsed = await withAiVaultWholeJsonFile(file.path, (content) => { + const record = parseJsonObject(content) + if (!record) { + return null + } + const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path) + const accumulator = createAccumulator({ agent: 'opencode', file, sessionId }) + accumulator.title = normalizeTitleText(extractString(record.title) ?? '') + accumulator.cwd = extractString(record.directory) + updateTimeline(accumulator, timeObjectValue(record.time, 'created')) + updateTimeline(accumulator, timeObjectValue(record.time, 'updated')) + return { accumulator, sessionId } + }) + if (!parsed) { return null } - const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path) - const accumulator = createAccumulator({ agent: 'opencode', file, sessionId }) - accumulator.title = normalizeTitleText(extractString(record.title) ?? '') - accumulator.cwd = extractString(record.directory) - updateTimeline(accumulator, timeObjectValue(record.time, 'created')) - updateTimeline(accumulator, timeObjectValue(record.time, 'updated')) - await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId) - return finalizeSession(accumulator, platform) + await consumeOpenCodeMessages( + parsed.accumulator, + findOpenCodeStorageRoot(file.path), + parsed.sessionId + ) + return finalizeSession(parsed.accumulator, platform) } export async function consumeOpenCodeMessages( @@ -229,44 +228,43 @@ export async function consumeOpenCodeMessages( return } const messageDir = join(storageRoot, 'message', sessionId) - let entries try { - entries = await readdir(messageDir, { withFileTypes: true }) - } catch { - return - } - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.json')) { - continue - } - const message = asRecord( - JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown - ) - if (!message) { - continue - } - const role = extractString(message.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - updateTimeline(accumulator, timeObjectValue(message.time, 'created')) - if (role === 'user') { - accumulator.title ??= extractString(asRecord(message.summary)?.title) - accumulator.title ??= extractString(asRecord(message.summary)?.body) + const directory = await opendir(messageDir) + for await (const entry of directory) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue } - addPreviewMessage(accumulator, { - role, - text: - extractPreviewContentText(message.content) ?? - extractString(asRecord(message.summary)?.body) ?? - extractString(asRecord(message.summary)?.title), - timestamp: timeObjectValue(message.time, 'created') + await withAiVaultWholeJsonFile(join(messageDir, entry.name), (content) => { + const message = parseJsonObject(content) + if (!message) { + return + } + const role = extractString(message.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + updateTimeline(accumulator, timeObjectValue(message.time, 'created')) + if (role === 'user') { + accumulator.title ??= extractString(asRecord(message.summary)?.title) + accumulator.title ??= extractString(asRecord(message.summary)?.body) + } + addPreviewMessage(accumulator, { + role, + text: + extractPreviewContentText(message.content) ?? + extractString(asRecord(message.summary)?.body) ?? + extractString(asRecord(message.summary)?.title), + timestamp: timeObjectValue(message.time, 'created') + }) + accumulator.model = + extractString(asRecord(message.model)?.modelID) || + extractString(message.modelID) || + accumulator.model + accumulator.totalTokens += tokenTotal(message.tokens) + } }) - accumulator.model = - extractString(asRecord(message.model)?.modelID) || - extractString(message.modelID) || - accumulator.model - accumulator.totalTokens += tokenTotal(message.tokens) } + } catch { + // Missing or unreadable message directories contribute no messages. } } @@ -274,16 +272,18 @@ export async function parseHermesSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform) + return withAiVaultWholeJsonFile(file.path, (content) => + parseHermesSessionContent(file, content, platform) + ) } export async function parseHermesSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ResumableParseFinalizeOptions = {} ): Promise { - const record = asRecord(JSON.parse(content) as unknown) + const record = parseJsonObject(content) if (!record) { return null } diff --git a/src/main/ai-vault/session-scanner-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-subagent-transcripts.ts index 0477f9dc229..bede82c0986 100644 --- a/src/main/ai-vault/session-scanner-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-subagent-transcripts.ts @@ -1,4 +1,4 @@ -import { readdir } from 'node:fs/promises' +import { opendir } from 'node:fs/promises' import { basename, dirname, extname, join } from 'node:path' // Exported so discovery can prune these subtrees using the same literal that @@ -36,13 +36,23 @@ export function subagentTranscriptsDirFor(transcriptFilePath: string): string { * transcripts and are excluded. */ export async function countSubagentTranscripts(transcriptFilePath: string): Promise { - let entries + let directory try { - entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath), { withFileTypes: true }) + directory = await opendir(subagentTranscriptsDirFor(transcriptFilePath)) } catch { return 0 } - return entries.filter((entry) => isSubagentTranscriptFileName(entry.name, entry.isFile())).length + let count = 0 + try { + for await (const entry of directory) { + if (isSubagentTranscriptFileName(entry.name, entry.isFile())) { + count += 1 + } + } + } catch { + // A disappearing directory still has the successfully counted prefix. + } + return count } // Direct child of a subagents dir: `//subagents/agent-.jsonl`. diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index f9af3f20897..91cd6c5c0db 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -87,6 +87,8 @@ export type ResumableParseFinalizeOptions = { export type ResumableSessionParseState = { consumeLine(line: string): void clone(): ResumableSessionParseState + // The cache budget must include fold state that persistence intentionally omits. + retainedUtf8Bytes(): number // Refresh per-scan file metadata (mtime display string) without re-parsing. touchFile(file: FileWithMtime): void finalize( diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index a9954ae8d26..bb93c4681fc 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -1,12 +1,22 @@ import { describe, expect, it, vi } from 'vitest' import { + AI_VAULT_JSON_STRUCTURE_LIMITS, extractPreviewContentText, normalizeAgentSessionsDir, normalizePreviewText, - normalizeTitleText + normalizeTitleText, + parseJsonObject } from './session-scanner-values' describe('AI Vault session scanner text values', () => { + it('rejects structurally amplified JSON before parsing', () => { + const parseSpy = vi.spyOn(JSON, 'parse') + const amplified = `[${'0,'.repeat(AI_VAULT_JSON_STRUCTURE_LIMITS.structuralTokens)}0]` + + expect(parseJsonObject(amplified)).toBeNull() + expect(parseSpy).not.toHaveBeenCalled() + }) + it('normalizes compact title text without surfacing hidden context blocks', () => { expect( normalizeTitleText( diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 39006bd68a4..71411eed12f 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -1,6 +1,22 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' -import { readFile } from 'node:fs/promises' +import { + assertJsonTextStructureWithinLimits, + type JsonTextStructureLimits +} from '../../shared/memory-safety/json-text-structure-limit' + +export const AI_VAULT_JSON_STRUCTURE_LIMITS: JsonTextStructureLimits = { + structuralTokens: 1_000_000, + nestingDepth: 256 +} + +export function parseAiVaultJsonText( + content: string, + limits: JsonTextStructureLimits = AI_VAULT_JSON_STRUCTURE_LIMITS +): unknown { + assertJsonTextStructureWithinLimits(content, limits) + return JSON.parse(content) as unknown +} export function timestampMs(value: unknown): number { if (typeof value === 'string') { @@ -18,7 +34,7 @@ export function parseJsonObject(line: string): Record | null { return null } try { - const parsed = JSON.parse(line) as unknown + const parsed = parseAiVaultJsonText(line) return asRecord(parsed) } catch { return null @@ -69,16 +85,6 @@ export function extractGitBranch(value: unknown): string | null { return extractString(git.branch) || extractString(git.current_branch) } -export async function readJsonObjectIfExists( - filePath: string -): Promise | null> { - try { - return asRecord(JSON.parse(await readFile(filePath, 'utf-8')) as unknown) - } catch { - return null - } -} - export function arrayValue(value: unknown): unknown[] { return Array.isArray(value) ? value : [] } diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index f1979c67626..99ead01f352 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,4 +1,3 @@ -import { readFile } from 'node:fs/promises' import type { AiVaultListResult, AiVaultScanIssue, @@ -7,6 +6,11 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' +import { + boundAiVaultListResult, + retainAiVaultSession, + retainAiVaultSessionsWithinAggregate +} from './session-list-retention' import { codexRolloutHardlinkIdentity, dedupeCodexRolloutFileAliases, @@ -39,6 +43,7 @@ import type { SessionParseResult } from './session-scanner-types' import { clampPositiveInteger, errorMessage } from './session-scanner-values' +import { withAiVaultWholeJsonFile } from './session-whole-json-reader' const DEFAULT_LIMIT = 1000 const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000 @@ -138,11 +143,11 @@ export async function scanAiVaultSessions( scheduleSessionParseCachePersist(parseStats) - return { + return boundAiVaultListResult({ sessions: mergeSessions(cappedSessions, scopeSessions), issues: issues.map((issue) => ({ executionHostId, ...issue })), scannedAt: new Date().toISOString() - } + }) }) } @@ -250,7 +255,17 @@ async function parseSessionCandidates(args: { // Why: cross-volume backfill copies have no shared inode, so collapse // parsed aliases before they can crowd the unique-session parse budget. const uniqueSessions = dedupeCodexSessionsBySessionId(sessions) - sessions.splice(0, sessions.length, ...uniqueSessions) + const retained = retainAiVaultSessionsWithinAggregate(uniqueSessions) + sessions.splice(0, sessions.length, ...retained.sessions) + if (retained.omitted > 0) { + args.issues.push({ + executionHostId: args.executionHostId, + agent: 'codex', + path: 'AI Vault session list', + message: `AI Vault stopped after omitting ${retained.omitted} sessions at its memory limit.` + }) + break + } index += batchSize } @@ -270,8 +285,9 @@ async function parseSessionCandidate( if (session && candidate.antigravityHistoryPath && antigravityWorkspaceResolver) { session = await antigravityWorkspaceResolver.enrich(session, candidate.antigravityHistoryPath) } + const stamped = session ? withSessionExecutionHost(session, executionHostId) : null return { - session: session ? withSessionExecutionHost(session, executionHostId) : null, + session: stamped ? retainAiVaultSession(stamped) : null, issue: null } } catch (err) { @@ -289,7 +305,7 @@ async function parseSessionCandidate( async function readOptionalTextFile(path: string): Promise { try { - return await readFile(path, 'utf-8') + return await withAiVaultWholeJsonFile(path, (content) => content) } catch { return null } diff --git a/src/main/ai-vault/session-scope-cwd-cache.test.ts b/src/main/ai-vault/session-scope-cwd-cache.test.ts new file mode 100644 index 00000000000..02eb96a9737 --- /dev/null +++ b/src/main/ai-vault/session-scope-cwd-cache.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + AI_VAULT_SCOPE_CWD_CACHE_KEY_MAX_UTF8_BYTES, + AI_VAULT_SCOPE_CWD_CACHE_VALUE_MAX_UTF8_BYTES, + AiVaultScopeCwdCache +} from './session-scope-cwd-cache' + +describe('AI Vault scope cwd cache', () => { + it('rejects keys and values one byte over their UTF-8 limits', () => { + const cache = new AiVaultScopeCwdCache() + const exactKey = 'é'.repeat(AI_VAULT_SCOPE_CWD_CACHE_KEY_MAX_UTF8_BYTES / 2) + const exactValue = 'é'.repeat(AI_VAULT_SCOPE_CWD_CACHE_VALUE_MAX_UTF8_BYTES / 2) + + cache.set(exactKey, exactValue) + cache.set(`${exactKey}x`, '/overflow-key') + cache.set('/overflow-value', `${exactValue}x`) + + expect(cache.inspectForTests().keys).toEqual([exactKey]) + }) + + it('evicts least-recently-used entries at the aggregate byte boundary', () => { + const entryBytes = Buffer.byteLength('/a/repo', 'utf8') + Buffer.byteLength('/a', 'utf8') + 128 + const cache = new AiVaultScopeCwdCache({ maxEntries: 3, maxRetainedBytes: entryBytes * 2 }) + + cache.set('/a/repo', '/a') + cache.set('/b/repo', '/b') + expect(cache.get('/a/repo')).toBe('/a') + cache.set('/c/repo', '/c') + + expect(cache.inspectForTests().keys).toEqual(['/a/repo', '/c/repo']) + }) +}) diff --git a/src/main/ai-vault/session-scope-cwd-cache.ts b/src/main/ai-vault/session-scope-cwd-cache.ts new file mode 100644 index 00000000000..d562728ab9d --- /dev/null +++ b/src/main/ai-vault/session-scope-cwd-cache.ts @@ -0,0 +1,77 @@ +export const AI_VAULT_SCOPE_CWD_CACHE_MAX_ENTRIES = 2_048 +export const AI_VAULT_SCOPE_CWD_CACHE_KEY_MAX_UTF8_BYTES = 32 * 1024 +export const AI_VAULT_SCOPE_CWD_CACHE_VALUE_MAX_UTF8_BYTES = 128 * 1024 +export const AI_VAULT_SCOPE_CWD_CACHE_MAX_RETAINED_UTF8_BYTES = 8 * 1024 * 1024 + +const ENTRY_OVERHEAD_BYTES = 128 + +type RetainedCwd = { cwd: string; bytes: number } + +export class AiVaultScopeCwdCache { + private readonly entries = new Map() + private retainedBytes = 0 + + constructor( + private readonly limits: { + maxEntries: number + maxRetainedBytes: number + } = { + maxEntries: AI_VAULT_SCOPE_CWD_CACHE_MAX_ENTRIES, + maxRetainedBytes: AI_VAULT_SCOPE_CWD_CACHE_MAX_RETAINED_UTF8_BYTES + } + ) {} + + get(projectDir: string): string | undefined { + const retained = this.entries.get(projectDir) + if (!retained) { + return undefined + } + this.entries.delete(projectDir) + this.entries.set(projectDir, retained) + return retained.cwd + } + + set(projectDir: string, cwd: string): void { + this.delete(projectDir) + const keyBytes = Buffer.byteLength(projectDir, 'utf8') + const valueBytes = Buffer.byteLength(cwd, 'utf8') + const bytes = keyBytes + valueBytes + ENTRY_OVERHEAD_BYTES + if ( + keyBytes > AI_VAULT_SCOPE_CWD_CACHE_KEY_MAX_UTF8_BYTES || + valueBytes > AI_VAULT_SCOPE_CWD_CACHE_VALUE_MAX_UTF8_BYTES || + bytes > this.limits.maxRetainedBytes + ) { + return + } + while ( + this.entries.size >= this.limits.maxEntries || + this.retainedBytes + bytes > this.limits.maxRetainedBytes + ) { + const oldest = this.entries.keys().next().value + if (oldest === undefined) { + return + } + this.delete(oldest) + } + this.entries.set(projectDir, { cwd, bytes }) + this.retainedBytes += bytes + } + + clear(): void { + this.entries.clear() + this.retainedBytes = 0 + } + + inspectForTests(): { keys: string[]; retainedBytes: number } { + return { keys: [...this.entries.keys()], retainedBytes: this.retainedBytes } + } + + private delete(projectDir: string): void { + const retained = this.entries.get(projectDir) + if (!retained) { + return + } + this.entries.delete(projectDir) + this.retainedBytes -= retained.bytes + } +} diff --git a/src/main/ai-vault/session-whole-json-reader.test.ts b/src/main/ai-vault/session-whole-json-reader.test.ts new file mode 100644 index 00000000000..1c524d988d3 --- /dev/null +++ b/src/main/ai-vault/session-whole-json-reader.test.ts @@ -0,0 +1,93 @@ +import { mkdtemp, open, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + AI_VAULT_WHOLE_JSON_MAX_BYTES, + AI_VAULT_JSON_READ_MAX_ACTIVE, + AI_VAULT_JSON_READ_MAX_WAITERS, + AiVaultJsonReadBudget, + withAiVaultWholeJsonFile +} from './session-whole-json-reader' + +describe('AI Vault whole-JSON read budget', () => { + it('queues aggregate bytes and releases admission idempotently', async () => { + const budget = new AiVaultJsonReadBudget(10) + const releaseSix = await budget.acquire(6) + let admitted = false + const second = budget.acquire(5).then((release) => { + admitted = true + return release + }) + + await Promise.resolve() + expect(admitted).toBe(false) + releaseSix() + const releaseFive = await second + expect(admitted).toBe(true) + + releaseSix() + releaseFive() + await expect(budget.acquire(10)).resolves.toEqual(expect.any(Function)) + }) + + it('rejects a single document larger than the process budget', () => { + const budget = new AiVaultJsonReadBudget(10) + + expect(() => budget.acquire(11)).toThrow('exceeds 10 byte limit') + }) + + it('bounds queued readers and admits them in FIFO order', async () => { + const budget = new AiVaultJsonReadBudget(1) + const releaseActive = await budget.acquire(1) + const queued = Array.from({ length: AI_VAULT_JSON_READ_MAX_WAITERS }, () => budget.acquire(1)) + + expect(() => budget.acquire(1)).toThrow('reader is busy') + releaseActive() + for (const pending of queued) { + const release = await pending + release() + } + await expect(budget.acquire(1)).resolves.toEqual(expect.any(Function)) + }) + + it('uses a fixed active-reader pool even when byte capacity remains', async () => { + const budget = new AiVaultJsonReadBudget(1024) + const releases = await Promise.all( + Array.from({ length: AI_VAULT_JSON_READ_MAX_ACTIVE }, () => budget.acquire(1)) + ) + let admitted = false + const queued = budget.acquire(1).then((release) => { + admitted = true + return release + }) + + await Promise.resolve() + expect(admitted).toBe(false) + releases[0]() + const releaseQueued = await queued + expect(admitted).toBe(true) + for (const release of releases.slice(1)) { + release() + } + releaseQueued() + }) + + it('rejects an oversized sparse file before reading or parsing it', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-vault-whole-json-')) + const path = join(directory, 'oversized.json') + const handle = await open(path, 'w') + await handle.truncate(AI_VAULT_WHOLE_JSON_MAX_BYTES + 1) + await handle.close() + const consume = vi.fn() + + try { + await expect(withAiVaultWholeJsonFile(path, consume)).rejects.toThrow( + `exceeds ${AI_VAULT_WHOLE_JSON_MAX_BYTES} byte limit` + ) + expect(consume).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true }) + } + }) +}) diff --git a/src/main/ai-vault/session-whole-json-reader.ts b/src/main/ai-vault/session-whole-json-reader.ts new file mode 100644 index 00000000000..f1abceebe7b --- /dev/null +++ b/src/main/ai-vault/session-whole-json-reader.ts @@ -0,0 +1,87 @@ +import { stat } from 'node:fs/promises' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' + +export const AI_VAULT_WHOLE_JSON_MAX_BYTES = 64 * 1024 * 1024 +export const AI_VAULT_JSON_READ_MAX_ACTIVE = 8 +export const AI_VAULT_JSON_READ_MAX_WAITERS = 256 +const WAIT_QUEUE_COMPACTION_HEAD = 64 + +type AdmissionWaiter = { + bytes: number + resolve: (release: () => void) => void +} + +export class AiVaultJsonReadBudget { + private retainedBytes = 0 + private activeReaders = 0 + private waiters: (AdmissionWaiter | undefined)[] = [] + private waiterHead = 0 + + constructor(readonly maxBytes: number) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + throw new RangeError('AI Vault JSON read budget must be a non-negative safe integer') + } + } + + acquire(bytes: number): Promise<() => void> { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.maxBytes) { + throw new RangeError(`AI Vault JSON document exceeds ${this.maxBytes} byte limit`) + } + if (this.waiters.length - this.waiterHead >= AI_VAULT_JSON_READ_MAX_WAITERS) { + throw new Error('AI Vault JSON reader is busy; retry after active reads finish') + } + return new Promise((resolve) => { + this.waiters.push({ bytes, resolve }) + this.admitWaiters() + }) + } + + private admitWaiters(): void { + while (this.waiterHead < this.waiters.length) { + const waiter = this.waiters[this.waiterHead]! + if ( + this.activeReaders >= AI_VAULT_JSON_READ_MAX_ACTIVE || + waiter.bytes > this.maxBytes - this.retainedBytes + ) { + return + } + this.waiters[this.waiterHead] = undefined + this.waiterHead += 1 + if (this.waiterHead >= WAIT_QUEUE_COMPACTION_HEAD) { + this.waiters.splice(0, this.waiterHead) + this.waiterHead = 0 + } + this.retainedBytes += waiter.bytes + this.activeReaders += 1 + let released = false + waiter.resolve(() => { + if (released) { + return + } + released = true + this.retainedBytes -= waiter.bytes + this.activeReaders -= 1 + this.admitWaiters() + }) + } + this.waiters.length = 0 + this.waiterHead = 0 + } +} + +const jsonReadBudget = new AiVaultJsonReadBudget(AI_VAULT_WHOLE_JSON_MAX_BYTES) + +export async function withAiVaultWholeJsonFile( + filePath: string, + consume: (content: string) => T | Promise +): Promise { + const fileStats = await stat(filePath) + const release = await jsonReadBudget.acquire(fileStats.size) + try { + // Reject concurrent growth: the admission covers exactly the statted bytes. + const { buffer } = await readNodeFileWithinLimit(filePath, fileStats.size) + return await consume(buffer.toString('utf8')) + } finally { + release() + } +} diff --git a/src/main/amp/hook-service.test.ts b/src/main/amp/hook-service.test.ts index 5ed0f6c5e3e..9b902a1f459 100644 --- a/src/main/amp/hook-service.test.ts +++ b/src/main/amp/hook-service.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { GENERATED_NODE_MANAGED_FILE_MAX_BYTES } from '../generated-node-bounded-file-reader' const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() @@ -59,6 +60,11 @@ describe('AmpHookService', () => { expect(source).not.toContain('postQueue = postQueue.then') expect(source).toContain('process.env.ORCA_PANE_KEY') expect(source).toContain('process.env.ORCA_AGENT_HOOK_ENDPOINT') + expect(source).toContain( + `function readOrcaManagedFileWithinLimit(fs: any, path: string, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES})` + ) + expect(source).toContain('readOrcaManagedFileWithinLimit(fs, endpointPath)') + expect(source).not.toContain('readFileSync') }) it('does not overwrite an existing user-authored Amp plugin file', () => { diff --git a/src/main/amp/hook-service.ts b/src/main/amp/hook-service.ts index 83ed74d1665..398021da939 100644 --- a/src/main/amp/hook-service.ts +++ b/src/main/amp/hook-service.ts @@ -3,12 +3,18 @@ emitted plugin bytes drift from the installer checks that protect user plugin files from being overwritten. */ import { randomUUID } from 'node:crypto' -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + NodeFileReadTooLargeError, + readNodeFileSyncWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' +import { getGeneratedNodeBoundedFileReaderSourceLines } from '../generated-node-bounded-file-reader' +import { AGENT_HOOK_PLUGIN_MAX_BYTES } from '../agent-hooks/agent-hook-file-limits' import { readTextFileRemote, writeTextFileRemoteAtomic @@ -53,7 +59,10 @@ function readLocalPluginState(pluginPath: string): PluginFileState { return { kind: 'absent' } } try { - const content = readFileSync(pluginPath, 'utf-8') + const content = readNodeFileSyncWithinLimit( + pluginPath, + AGENT_HOOK_PLUGIN_MAX_BYTES + ).buffer.toString('utf8') if (!isManagedPlugin(content)) { return { kind: 'unmanaged' } } @@ -105,10 +114,17 @@ function writeTextFileAtomic(filePath: string, content: string): void { mkdirSync(dir, { recursive: true }) if (existsSync(filePath)) { try { - if (readFileSync(filePath, 'utf-8') === content) { + const existing = readNodeFileSyncWithinLimit( + filePath, + AGENT_HOOK_PLUGIN_MAX_BYTES + ).buffer.toString('utf8') + if (existing === content) { return } - } catch { + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw error + } // Fall through to the atomic write path. } } @@ -130,12 +146,14 @@ function writeTextFileAtomic(filePath: string, content: string): void { function getAmpPluginSource(): string { return [ - "import { readFileSync, statSync } from 'fs'", + "import * as fs from 'fs'", "import type { PluginAPI } from '@ampcode/plugin'", '', `// ${AMP_PLUGIN_MARKER}`, 'type HookCoords = { port?: string; token?: string; env?: string; version?: string }', '', + ...getGeneratedNodeBoundedFileReaderSourceLines({ typed: true }), + '', 'let warnedBadEndpoint = false', "let cachedEndpointKey = ''", 'let cachedEndpointValues: HookCoords | null = null', @@ -144,12 +162,12 @@ function getAmpPluginSource(): string { ' const endpointPath = process.env.ORCA_AGENT_HOOK_ENDPOINT', ' if (!endpointPath) return null', ' try {', - ' const stat = statSync(endpointPath)', + ' const stat = fs.statSync(endpointPath)', ' const cacheKey = `${stat.mtimeMs}:${stat.size}:${stat.ino}`', ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', ' return cachedEndpointValues', ' }', - " const contents = readFileSync(endpointPath, 'utf8')", + ' const contents = readOrcaManagedFileWithinLimit(fs, endpointPath)', ' const out: HookCoords = {}', ' for (const line of contents.split(/\\r?\\n/)) {', ' const match = line.match(/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/)', @@ -346,11 +364,16 @@ export class AmpHookService { async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { const remotePluginPath = getRemotePluginPath(remoteHome) try { - const existing = await readTextFileRemote(sftp, remotePluginPath) + const existing = await readTextFileRemote(sftp, remotePluginPath, AGENT_HOOK_PLUGIN_MAX_BYTES) if (existing !== null && !isManagedPlugin(existing)) { return statusFromState(remotePluginPath, { kind: 'unmanaged' }) } - await writeTextFileRemoteAtomic(sftp, remotePluginPath, getAmpPluginSource()) + await writeTextFileRemoteAtomic( + sftp, + remotePluginPath, + getAmpPluginSource(), + AGENT_HOOK_PLUGIN_MAX_BYTES + ) return { agent: 'amp', state: 'installed', diff --git a/src/main/automations/external-job-mappers.ts b/src/main/automations/external-job-mappers.ts index d3648af446a..6a1f99783b3 100644 --- a/src/main/automations/external-job-mappers.ts +++ b/src/main/automations/external-job-mappers.ts @@ -200,6 +200,7 @@ export function mapHermesJobs(managerId: string, rawJobs: unknown): ExternalAuto lastError: asString(job.last_error) ?? asString(job.last_delivery_error), workdir: asString(job.workdir), runCount: asNumber(job.run_count) ?? (Array.isArray(job.runs) ? job.runs.length : 0), + ...(job.run_count_saturated === true ? { runCountSaturated: true as const } : {}), runs: mapExternalRuns({ managerId, provider: 'hermes', diff --git a/src/main/automations/external-manager-remote-fanout.test.ts b/src/main/automations/external-manager-remote-fanout.test.ts new file mode 100644 index 00000000000..15353016e66 --- /dev/null +++ b/src/main/automations/external-manager-remote-fanout.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import type { Store } from '../persistence' +import type { SshTarget } from '../../shared/ssh-types' +import { getActiveMultiplexer } from '../ipc/ssh' +import { listExternalAutomationManagers } from './external-manager' + +const execFileMock = vi.hoisted(() => + vi.fn( + ( + _command: string, + _args: string[], + _options: unknown, + callback: (error: Error | null) => void + ) => { + callback(new Error('not installed')) + return { kill: vi.fn() } + } + ) +) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { ...actual, existsSync: vi.fn(() => false) } +}) +vi.mock('../ipc/ssh', () => ({ getActiveMultiplexer: vi.fn() })) + +beforeEach(() => { + vi.mocked(getActiveMultiplexer).mockReset() +}) + +describe('remote external automation manager fanout', () => { + it('bounds probes for a large target list and preserves provider order', async () => { + const targets: SshTarget[] = Array.from({ length: 100 }, (_, index) => ({ + id: `ssh-${index}`, + label: `Target ${index}`, + host: `host-${index}`, + port: 22, + username: 'orca' + })) + let inFlight = 0 + let peak = 0 + const request = vi.fn(async (_method: string, input: { provider: string }) => { + inFlight += 1 + peak = Math.max(peak, inFlight) + await Promise.resolve() + inFlight -= 1 + return { + jobs: [], + hermesAvailable: input.provider === 'hermes', + openclawAvailable: input.provider === 'openclaw' + } + }) + vi.mocked(getActiveMultiplexer).mockReturnValue({ + isDisposed: () => false, + request + } as never) + + const managers = await listExternalAutomationManagers({ + getSshTargets: () => targets + } as Store) + + expect(peak).toBe(4) + expect(request).toHaveBeenCalledTimes(200) + expect(managers.map((manager) => manager.id)).toEqual( + targets.flatMap((target) => [`hermes:ssh:${target.id}`, `openclaw:ssh:${target.id}`]) + ) + }) +}) diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index dc96b9b964a..59c104197a3 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -344,6 +344,7 @@ describe('listExternalAutomationRuns', () => { it('requests paginated Hermes runs from the remote relay', async () => { const request = vi.fn().mockResolvedValue({ total: 42, + totalSaturated: true, runs: [ { id: 'job-1:2026-05-15_09-00-00.md', @@ -375,6 +376,7 @@ describe('listExternalAutomationRuns', () => { page: 2, pageSize: 10, total: 42, + totalSaturated: true, runs: [ { id: 'job-1:2026-05-15_09-00-00.md', diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index 8c4d0f0d53e..b5f9d698782 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -2,7 +2,6 @@ * and lifecycle routing share provider/target validation and remote relay fallbacks. */ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import type { @@ -15,6 +14,8 @@ import type { ExternalAutomationRunsPage, ExternalAutomationUpdateInput } from '../../shared/automations-types' +import { readExternalAutomationJobsFile } from '../../shared/external-automation-jobs-file' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' import type { SshTarget } from '../../shared/ssh-types' import type { Store } from '../persistence' import { getActiveMultiplexer } from '../ipc/ssh' @@ -32,6 +33,8 @@ const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000 const LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS = 30_000 +const HERMES_JOB_RUN_COUNT_CONCURRENCY = 4 +const REMOTE_AUTOMATION_MANAGER_FETCH_CONCURRENCY = 4 function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -130,39 +133,30 @@ async function readLocalHermesJobs(): Promise { if (!existsSync(HERMES_JOBS_FILE)) { return [] } - const content = await readFile(HERMES_JOBS_FILE, 'utf-8') - const parsed = JSON.parse(content) as unknown - const jobs = Array.isArray(parsed) - ? parsed - : isRecord(parsed) && Array.isArray(parsed.jobs) - ? parsed.jobs - : [] - return Promise.all( - jobs.map(async (job) => { - if (!isRecord(job)) { - return job - } - const jobId = typeof job.id === 'string' ? job.id : null - if (!jobId) { - return job - } - const runsPage = await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: 0 }) - return { - ...job, - run_count: runsPage.total, - runs: [] - } - }) - ) + const jobs = await readExternalAutomationJobsFile(HERMES_JOBS_FILE, { allowRootArray: true }) + return mapWithConcurrency(jobs, HERMES_JOB_RUN_COUNT_CONCURRENCY, async (job) => { + if (!isRecord(job)) { + return job + } + const jobId = typeof job.id === 'string' ? job.id : null + if (!jobId) { + return job + } + const runsPage = await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: 0 }) + return { + ...job, + run_count: runsPage.total, + ...(runsPage.totalSaturated ? { run_count_saturated: true } : {}), + runs: [] + } + }) } async function readLocalOpenClawJobs(): Promise { if (!existsSync(OPENCLAW_JOBS_FILE)) { return [] } - const content = await readFile(OPENCLAW_JOBS_FILE, 'utf-8') - const parsed = JSON.parse(content) as unknown - return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : [] + return readExternalAutomationJobsFile(OPENCLAW_JOBS_FILE, { allowRootArray: false }) } async function listLocalHermesManager(): Promise { @@ -300,16 +294,22 @@ async function listRemoteManager( export async function listExternalAutomationManagers( store: Store ): Promise { + const remoteManagerRequests = store + .getSshTargets() + // Why: runtime-owned hidden targets are excluded from SSH/run-target surfaces. + .filter((target) => !isRuntimeOwnedSshTarget(target)) + .flatMap((target) => [ + { provider: 'hermes' as const, target }, + { provider: 'openclaw' as const, target } + ]) const [localHermes, localOpenClaw, remote] = await Promise.all([ listLocalHermesManager(), listLocalOpenClawManager(), - Promise.all( - store - .getSshTargets() - // Why: runtime-owned hidden targets are excluded from SSH/run-target - // surfaces; don't probe them for external automations either. - .filter((target) => !isRuntimeOwnedSshTarget(target)) - .flatMap((target) => [listRemoteHermesManager(target), listRemoteOpenClawManager(target)]) + mapWithConcurrency( + remoteManagerRequests, + REMOTE_AUTOMATION_MANAGER_FETCH_CONCURRENCY, + ({ provider, target }) => + provider === 'hermes' ? listRemoteHermesManager(target) : listRemoteOpenClawManager(target) ) ]) return [ @@ -351,6 +351,7 @@ export async function listExternalAutomationRuns( page, pageSize, total: result.total, + ...(result.totalSaturated ? { totalSaturated: true } : {}), runs: mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs }])[0]?.runs ?? [] } } @@ -363,7 +364,7 @@ export async function listExternalAutomationRuns( jobId: input.jobId, page, pageSize - })) as { total?: number; runs?: unknown[] } + })) as { total?: number; totalSaturated?: boolean; runs?: unknown[] } return { managerId: input.managerId, provider: input.provider, @@ -372,6 +373,7 @@ export async function listExternalAutomationRuns( page, pageSize, total: typeof result.total === 'number' && Number.isFinite(result.total) ? result.total : 0, + ...(result.totalSaturated === true ? { totalSaturated: true } : {}), runs: mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs ?? [] }])[0]?.runs ?? [] } diff --git a/src/main/automations/headless-dispatch.ts b/src/main/automations/headless-dispatch.ts index 359673bc573..00cca368e35 100644 --- a/src/main/automations/headless-dispatch.ts +++ b/src/main/automations/headless-dispatch.ts @@ -4,6 +4,7 @@ import type { AutomationRunOutputSnapshot } from '../../shared/automations-types' import type { AutomationRunTargetResult } from './run-target-resolution' +import { appendCompactedStringChunk } from '../../shared/string-chunk-compaction' const MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 @@ -39,7 +40,7 @@ export function createHeadlessAutomationOutputSnapshotBuffer(): { if (!chunk) { return } - chunks.push(chunk) + appendCompactedStringChunk(chunks, chunk) totalChars += chunk.length let overflowChars = totalChars - MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS while (overflowChars > 0 && chunks.length > 0) { diff --git a/src/main/automations/hermes-cron-output.test.ts b/src/main/automations/hermes-cron-output.test.ts index 5bf9b2fddb5..2419863ae2e 100644 --- a/src/main/automations/hermes-cron-output.test.ts +++ b/src/main/automations/hermes-cron-output.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +11,7 @@ const fakeDbRows = vi.hoisted(() => ({ messages: [] as Record[] })) const fakePrepareSqls = vi.hoisted(() => [] as string[]) +const fakeIteratedSqls = vi.hoisted(() => [] as string[]) const fakeDatabase = vi.hoisted(() => vi.fn(function FakeDatabase() { return { @@ -26,7 +27,14 @@ const fakeDatabase = vi.hoisted(() => sql.includes('FROM sessions') ? fakeDbRows.sessions : fakeDbRows.messages.filter((message) => message.session_id === param) - ) + ), + iterate: vi.fn(function* (param: string) { + fakeIteratedSqls.push(sql) + const rows = sql.includes('FROM sessions') + ? fakeDbRows.sessions + : fakeDbRows.messages.filter((message) => message.session_id === param) + yield* rows + }) } }), close: vi.fn() @@ -53,6 +61,7 @@ beforeEach(() => { fakeDbRows.sessions = [] fakeDbRows.messages = [] fakePrepareSqls.length = 0 + fakeIteratedSqls.length = 0 fakeDatabase.mockClear() }) @@ -141,6 +150,26 @@ Run summary: monitor automation completed successfully. expect((page.runs[0] as { output_content?: string }).output_content).toContain( 'full command output line' ) + const transcriptSql = fakeIteratedSqls.find((sql) => sql.includes('FROM messages')) + expect(transcriptSql).toContain('substr(CAST(content AS BLOB)') + expect(transcriptSql).toContain('LIMIT 10001') + }) + + it('omits an oversized sparse markdown output without reading it wholesale', async () => { + const home = await createHermesHome() + const outputDir = join(home, 'cron', 'output', 'job-1') + const outputPath = join(outputDir, '2026-05-15_09-02-00.md') + await mkdir(outputDir, { recursive: true }) + await writeFile(outputPath, '', 'utf-8') + await truncate(outputPath, 64 * 1024 * 1024) + + const { readHermesCronOutputRunsPage } = await loadReader() + const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 }) + + expect(page.runs[0]).toMatchObject({ + output_content: null, + error: expect.stringContaining('File too large') + }) }) it('builds large response previews without broad regex captures', async () => { @@ -277,6 +306,47 @@ Run summary: monitor automation completed successfully. expect(fakePrepareSqls.some((sql) => sql.includes('FROM messages'))).toBe(false) }) + it('caps one hydrated history page at 100 runs', async () => { + const home = await createHermesHome() + const outputDir = join(home, 'cron', 'output', 'job-1') + await mkdir(outputDir, { recursive: true }) + await Promise.all( + Array.from({ length: 101 }, async (_, index) => { + const minute = String(Math.floor(index / 60)).padStart(2, '0') + const second = String(index % 60).padStart(2, '0') + await writeFile( + join(outputDir, `2026-05-15_09-${minute}-${second}.md`), + `run ${index}`, + 'utf-8' + ) + }) + ) + + const { readHermesCronOutputRunsPage } = await loadReader() + const page = await readHermesCronOutputRunsPage('job-1', { + page: 1, + pageSize: Number.MAX_SAFE_INTEGER + }) + + expect(page.total).toBe(101) + expect(page.runs).toHaveLength(100) + }) + + it('bounds session run refs and reports a saturated total', async () => { + const home = await createHermesHome() + await writeFile(join(home, 'state.db'), '', 'utf-8') + fakeDbRows.sessions = Array.from({ length: 10_001 }, (_, index) => ({ + id: `cron_job-1_20260515_${String(index).padStart(6, '0')}`, + started_at: Date.UTC(2026, 4, 15, 9, 0, 0) / 1000 - index + })) + + const { readHermesCronOutputRunsPage } = await loadReader() + const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 }) + + expect(page).toEqual({ total: 10_000, totalSaturated: true, runs: [] }) + expect(fakeIteratedSqls.find((sql) => sql.includes('FROM sessions'))).toContain('LIMIT 10001') + }) + it('caches count-only reads until the cache is cleared', async () => { const home = await createHermesHome() const outputDir = join(home, 'cron', 'output', 'job-1') diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index db1e054f7e8..b7225e72e0a 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -1,9 +1,25 @@ /* eslint-disable max-lines -- Why: Hermes run history has to reconcile * markdown output files with SQLite session transcripts from separate stores. */ import { existsSync } from 'node:fs' -import { open, readdir, readFile, realpath, stat } from 'node:fs/promises' +import { open, opendir, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + type BoundedHermesRunRefs, + HERMES_RUN_REF_MAX_ENTRIES, + HERMES_SESSION_RUN_REFS_SELECT_SQL, + HermesRunRefRetainer +} from '../../shared/hermes-run-ref-retention' +import { + formatHermesSessionMessagesWithinLimits, + HERMES_PRIMARY_OUTPUT_MAX_BYTES, + HERMES_RUN_PAGE_MAX_RUNS, + HERMES_SESSION_RUN_SELECT_SQL, + HERMES_SESSION_TRANSCRIPT_SELECT_SQL, + HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR, + hydrateHermesRunPageWithinLimits +} from '../../shared/hermes-run-output-limits' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' import Database from '../sqlite/sync-database' const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') @@ -22,6 +38,7 @@ const LATEST_LOG_PATH_PATTERN = export type HermesCronOutputRunsPage = { total: number + totalSaturated?: true runs: unknown[] } @@ -362,9 +379,10 @@ async function readReferencedLogFile(content: string): Promise<{ return null } if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { + const { buffer } = await readNodeFileWithinLimit(logPath, MAX_REFERENCED_LOG_BYTES) return { path: logPath, - content: await readFile(logPath, 'utf-8'), + content: buffer.toString('utf-8'), truncated: false } } @@ -411,31 +429,6 @@ async function appendReferencedLogFile(content: string): Promise { .join('\n\n') } -function formatSessionMessages(messages: Record[]): string | null { - if (messages.length === 0) { - return null - } - return messages - .map((message) => { - const role = typeof message.role === 'string' ? message.role : 'message' - const content = typeof message.content === 'string' ? message.content.trim() : '' - const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' - const reasoning = - typeof message.reasoning_content === 'string' - ? message.reasoning_content.trim() - : typeof message.reasoning === 'string' - ? message.reasoning.trim() - : '' - const parts = [ - `## ${role}${toolName ? ` / ${toolName}` : ''}`, - reasoning ? `### Reasoning\n\n${reasoning}` : null, - content || '(empty)' - ].filter(Boolean) - return parts.join('\n\n') - }) - .join('\n\n---\n\n') -} - function getRunKey(run: unknown): string | null { return isRecord(run) ? asString(run.run_key) : null } @@ -579,18 +572,20 @@ export async function readHermesCronOutputRuns(jobId: string): Promise { +async function readHermesCronOutputRunRefs( + jobId: string +): Promise> { const outputRuns = await readHermesOutputFileRunRefs(jobId) - return mergeHermesOutputAndSessionRunRefs(outputRuns, readHermesSessionDbRunRefs(jobId)).sort( - (a, b) => { - const aTime = getRawRunTime(a) - const bTime = getRawRunTime(b) - if (Number.isFinite(aTime) && Number.isFinite(bTime)) { - return bTime - aTime - } - return getRawRunId(b).localeCompare(getRawRunId(a)) - } - ) + const sessionRuns = readHermesSessionDbRunRefs(jobId) + const mergedRetainer = new HermesRunRefRetainer() + for (const ref of mergeHermesOutputAndSessionRunRefs(outputRuns.refs, sessionRuns.refs)) { + mergedRetainer.add(ref) + } + const merged = mergedRetainer.finish() + return { + refs: merged.refs, + saturated: outputRuns.saturated || sessionRuns.saturated || merged.saturated + } } // Why: opening the Automations page calls readHermesCronOutputRunsPage with @@ -600,9 +595,10 @@ async function readHermesCronOutputRunRefs(jobId: string): Promise + promise: Promise expiresAt: number } +type HermesRunCount = { total: number; totalSaturated?: true } const hermesRunCountCache = new Map() export function clearHermesCronOutputRunCountCache(jobId?: string): void { @@ -628,7 +624,7 @@ function pruneHermesRunCountCache(now: number): void { } } -async function readHermesCronOutputRunCount(jobId: string): Promise { +async function readHermesCronOutputRunCount(jobId: string): Promise { const now = Date.now() const cached = hermesRunCountCache.get(jobId) if (cached && cached.expiresAt > now) { @@ -641,7 +637,10 @@ async function readHermesCronOutputRunCount(jobId: string): Promise { // size bound and expired sweep, a long session can pin stale job ids forever. pruneHermesRunCountCache(now) const entry: HermesRunCountCacheEntry = { - promise: readHermesCronOutputRunRefs(jobId).then((refs) => refs.length), + promise: readHermesCronOutputRunRefs(jobId).then((result) => ({ + total: result.refs.length, + ...(result.saturated ? { totalSaturated: true as const } : {}) + })), expiresAt: Number.POSITIVE_INFINITY } hermesRunCountCache.set(jobId, entry) @@ -685,57 +684,54 @@ export async function readHermesCronOutputRunsPage( return { total: 0, runs: [] } } const safePage = Math.max(1, Math.floor(page)) - const safePageSize = Math.max(0, Math.floor(pageSize)) + const safePageSize = Math.min(HERMES_RUN_PAGE_MAX_RUNS, Math.max(0, Math.floor(pageSize))) if (safePageSize === 0) { // Why: manager listing only needs a badge count; hydrating markdown logs // and full session transcripts can make opening Automations very slow. - return { total: await readHermesCronOutputRunCount(jobId), runs: [] } + return { ...(await readHermesCronOutputRunCount(jobId)), runs: [] } } const runRefs = await readHermesCronOutputRunRefs(jobId) const start = (safePage - 1) * safePageSize - const pageRefs = runRefs.slice(start, start + safePageSize) + const pageRefs = runRefs.refs.slice(start, start + safePageSize) return { - total: runRefs.length, - runs: await Promise.all(pageRefs.map((ref) => hydrateHermesRunRef(jobId, ref))) + total: runRefs.refs.length, + ...(runRefs.saturated ? { totalSaturated: true } : {}), + runs: await hydrateHermesRunPageWithinLimits(pageRefs, (ref) => hydrateHermesRunRef(jobId, ref)) } } -function getRawRunId(run: unknown): string { - if (typeof run === 'object' && run !== null && 'id' in run) { - return String((run as { id: unknown }).id) - } - return '' -} - -function getRawRunTime(run: unknown): number { - if (typeof run !== 'object' || run === null || !('run_at' in run)) { - return Number.NaN - } - const runAt = (run as { run_at: unknown }).run_at - return typeof runAt === 'string' ? Date.parse(runAt) : Number.NaN -} - -async function readHermesOutputFileRunRefs(jobId: string): Promise { +async function readHermesOutputFileRunRefs( + jobId: string +): Promise> { const outputDir = join(HERMES_OUTPUT_DIR, jobId) if (!existsSync(outputDir)) { - return [] + return { refs: [], saturated: false } } - const entries = await readdir(outputDir, { withFileTypes: true }) - return entries - .filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) - .map((entry) => ({ + const retainer = new HermesRunRefRetainer() + const directory = await opendir(outputDir) + for await (const entry of directory) { + if (!entry.isFile() || !HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) { + continue + } + retainer.add({ kind: 'output' as const, id: `${jobId}:${entry.name}`, job_id: jobId, run_at: runAtFromHermesOutputFile(entry.name), run_key: runKeyFromHermesOutputFile(entry.name), output_path: join(outputDir, entry.name) - })) + }) + } + return retainer.finish() } async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { try { - const content = await readFile(ref.output_path, 'utf-8') + const { buffer } = await readNodeFileWithinLimit( + ref.output_path, + HERMES_PRIMARY_OUTPUT_MAX_BYTES + ) + const content = buffer.toString('utf-8') const parsed = parseHermesOutput(content) const outputContent = await appendReferencedLogFile(parsed.outputContent) return { @@ -764,37 +760,41 @@ async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { if (!existsSync(HERMES_STATE_DB)) { - return [] + return { refs: [], saturated: false } } try { const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) try { const pattern = `cron\\_${escapeSqlLike(jobId)}\\_%` - const rows = db - .prepare( - `SELECT id, started_at - FROM sessions - WHERE id LIKE ? ESCAPE '\\' - ORDER BY started_at DESC` - ) - .all(pattern) as Record[] - return rows.map((row) => { + const statement = db.prepare(HERMES_SESSION_RUN_REFS_SELECT_SQL) + const rows = + typeof statement.iterate === 'function' + ? (statement.iterate(pattern) as Iterable>) + : (statement.all(pattern) as Record[]) + const retainer = new HermesRunRefRetainer() + let read = 0 + for (const row of rows) { + if (read >= HERMES_RUN_REF_MAX_ENTRIES + 1) { + break + } + read += 1 const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}` - return { + retainer.add({ kind: 'session', id: runId, job_id: jobId, run_at: runAtFromUnixSeconds(row.started_at), run_key: runId.split(`${jobId}_`).at(-1) ?? null - } - }) + }) + } + return retainer.finish() } finally { db.close() } } catch { - return [] + return { refs: [], saturated: false } } } @@ -805,25 +805,18 @@ function readHermesSessionDbRunById(jobId: string, runId: string): unknown | nul try { const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) try { - const row = db - .prepare( - `SELECT id, title, started_at, ended_at, end_reason, model, message_count, - input_tokens, output_tokens, estimated_cost_usd - FROM sessions - WHERE id = ?` - ) - .get(runId) as Record | undefined + const row = db.prepare(HERMES_SESSION_RUN_SELECT_SQL).get(runId) as + | Record + | undefined if (!row) { return null } - const messages = db - .prepare( - `SELECT role, content, tool_name, reasoning, reasoning_content - FROM messages - WHERE session_id = ? - ORDER BY timestamp, id` - ) - .all(runId) as Record[] + const messageStatement = db.prepare(HERMES_SESSION_TRANSCRIPT_SELECT_SQL) + const messages = + typeof messageStatement.iterate === 'function' + ? (messageStatement.iterate(runId) as Iterable>) + : (messageStatement.all(runId) as Record[]) + const formattedMessages = formatHermesSessionMessagesWithinLimits(messages) const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null const messageCount = typeof row.message_count === 'number' ? row.message_count : null @@ -843,8 +836,8 @@ function readHermesSessionDbRunById(jobId: string, runId: string): unknown | nul run_key: runId.split(`${jobId}_`).at(-1) ?? null, status: typeof row.ended_at === 'number' ? 'completed' : 'unknown', output_preview: summaryParts.join(' · ') || null, - output_content: formatSessionMessages(messages), - error: null, + output_content: formattedMessages.content, + error: formattedMessages.truncated ? HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR : null, output_path: HERMES_STATE_DB } } finally { diff --git a/src/main/azure-devops/azure-devops-api-request.ts b/src/main/azure-devops/azure-devops-api-request.ts index 5fc8f256793..d5dfe3e9a33 100644 --- a/src/main/azure-devops/azure-devops-api-request.ts +++ b/src/main/azure-devops/azure-devops-api-request.ts @@ -1,6 +1,7 @@ import { Buffer } from 'node:buffer' import type { AzureDevOpsRepoRef } from './repository-ref' import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' const REQUEST_TIMEOUT_MS = 5000 @@ -95,7 +96,7 @@ export async function requestAzureDevOpsJsonAtBase( } return null } - return (await response.json()) as T + return await readFetchResponseJsonWithinLimit(response) } catch (error) { if (throwOnFailure) { throw error diff --git a/src/main/azure-devops/repository-ref.ts b/src/main/azure-devops/repository-ref.ts index 868d0d8c9bc..a826681b297 100644 --- a/src/main/azure-devops/repository-ref.ts +++ b/src/main/azure-devops/repository-ref.ts @@ -1,5 +1,9 @@ import { gitExecFileAsync } from '../git/runner' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { + buildRepositoryRefCacheKey, + RepositoryRefCache +} from '../source-control/repository-ref-cache' export type AzureDevOpsRepoRef = { host: string @@ -14,8 +18,7 @@ type LocalGitExecOptions = { wslDistro?: string } -const REPO_REF_CACHE_MAX_ENTRIES = 512 -const repoRefCache = new Map() +const repoRefCache = new RepositoryRefCache() /** @internal - exposed for tests only */ export function _resetAzureDevOpsRepoRefCache(): void { @@ -27,17 +30,6 @@ export function _getAzureDevOpsRepoRefCacheSize(): number { return repoRefCache.size } -function rememberRepoRefCacheEntry(cacheKey: string, value: AzureDevOpsRepoRef | null): void { - repoRefCache.set(cacheKey, value) - while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { - const oldestKey = repoRefCache.keys().next().value - if (oldestKey === undefined) { - return - } - repoRefCache.delete(oldestKey) - } -} - function decodeSegment(value: string): string { try { return decodeURIComponent(value) @@ -212,9 +204,10 @@ export async function getAzureDevOpsRepoRefForRemote( localGitOptions: LocalGitExecOptions = {} ): Promise { const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` - if (repoRefCache.has(cacheKey)) { - return repoRefCache.get(cacheKey)! + const cacheKey = buildRepositoryRefCacheKey([runtimeKey, repoPath, remoteName]) + const cached = repoRefCache.get(cacheKey) + if (cached.found) { + return cached.value } try { const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null @@ -228,7 +221,20 @@ export async function getAzureDevOpsRepoRefForRemote( ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const result = parseAzureDevOpsRepoRef(stdout) - rememberRepoRefCacheEntry(cacheKey, result) + repoRefCache.remember( + cacheKey, + result, + result + ? [ + result.host, + result.project, + result.repository, + result.apiBaseUrl, + result.webBaseUrl, + result.organization ?? '' + ] + : [] + ) return result } catch { if (connectionId) { @@ -236,7 +242,7 @@ export async function getAzureDevOpsRepoRefForRemote( // caching them as "not Azure DevOps" would poison the repo for the session. return null } - rememberRepoRefCacheEntry(cacheKey, null) + repoRefCache.remember(cacheKey, null, []) return null } } diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index e5163bd525a..ca124bb07ef 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -15,6 +15,7 @@ import { type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0' const REQUEST_TIMEOUT_MS = 5000 @@ -113,7 +114,7 @@ async function requestJson( } return null } - return (await response.json()) as T + return await readFetchResponseJsonWithinLimit(response) } catch (error) { if (throwOnFailure) { throw error diff --git a/src/main/bitbucket/repository-ref.ts b/src/main/bitbucket/repository-ref.ts index fb07c32d4bc..a4b743157de 100644 --- a/src/main/bitbucket/repository-ref.ts +++ b/src/main/bitbucket/repository-ref.ts @@ -1,5 +1,9 @@ import { gitExecFileAsync } from '../git/runner' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { + buildRepositoryRefCacheKey, + RepositoryRefCache +} from '../source-control/repository-ref-cache' export type BitbucketRepoRef = { workspace: string @@ -10,8 +14,7 @@ type LocalGitExecOptions = { wslDistro?: string } -const REPO_REF_CACHE_MAX_ENTRIES = 512 -const repoRefCache = new Map() +const repoRefCache = new RepositoryRefCache() /** @internal - exposed for tests only */ export function _resetBitbucketRepoRefCache(): void { @@ -23,17 +26,6 @@ export function _getBitbucketRepoRefCacheSize(): number { return repoRefCache.size } -function rememberRepoRefCacheEntry(cacheKey: string, value: BitbucketRepoRef | null): void { - repoRefCache.set(cacheKey, value) - while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { - const oldestKey = repoRefCache.keys().next().value - if (oldestKey === undefined) { - return - } - repoRefCache.delete(oldestKey) - } -} - function decodeSegment(value: string): string { try { return decodeURIComponent(value) @@ -87,9 +79,10 @@ export async function getBitbucketRepoRefForRemote( localGitOptions: LocalGitExecOptions = {} ): Promise { const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` - if (repoRefCache.has(cacheKey)) { - return repoRefCache.get(cacheKey)! + const cacheKey = buildRepositoryRefCacheKey([runtimeKey, repoPath, remoteName]) + const cached = repoRefCache.get(cacheKey) + if (cached.found) { + return cached.value } try { const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null @@ -103,7 +96,7 @@ export async function getBitbucketRepoRefForRemote( ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const result = parseBitbucketRepoRef(stdout) - rememberRepoRefCacheEntry(cacheKey, result) + repoRefCache.remember(cacheKey, result, result ? [result.workspace, result.repoSlug] : []) return result } catch { if (connectionId) { @@ -111,7 +104,7 @@ export async function getBitbucketRepoRefForRemote( // caching them as "not Bitbucket" would poison the repo for the session. return null } - rememberRepoRefCacheEntry(cacheKey, null) + repoRefCache.remember(cacheKey, null, []) return null } } diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index dc804a2dbe1..6a708937382 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -1,23 +1,25 @@ /* eslint-disable max-lines */ import { describe, it, expect, vi, beforeEach } from 'vitest' -const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } = +const { execFileMock, webContentsFromIdMock, existsSyncMock, readScreenshotFileMock, stdinWrites } = vi.hoisted(() => ({ execFileMock: vi.fn(), webContentsFromIdMock: vi.fn(), existsSyncMock: vi.fn(() => false), - readFileSyncMock: vi.fn(() => Buffer.from('')), + readScreenshotFileMock: vi.fn(() => Buffer.from('')), stdinWrites: [] as string[] })) vi.mock('child_process', () => ({ execFile: execFileMock })) vi.mock('fs', () => ({ existsSync: existsSyncMock, - readFileSync: readFileSyncMock, accessSync: vi.fn(), chmodSync: vi.fn(), constants: { X_OK: 1 } })) +vi.mock('./browser-screenshot-file-reader', () => ({ + readBrowserScreenshotFile: readScreenshotFileMock +})) vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' })) vi.mock('electron', () => { return { @@ -53,10 +55,16 @@ vi.mock('./cdp-bridge', () => ({ import { AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES, + AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION, AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, AgentBrowserBridge } from './agent-browser-bridge' import type { BrowserManager } from './browser-manager' +import { + BROWSER_SCREENSHOT_MAX_DIMENSION_PX, + BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR +} from './browser-screenshot-limits' +import { CDP_PDF_MAX_RETAINED_BYTES, CDP_PDF_MEMORY_LIMIT_ERROR } from './cdp-print-to-pdf' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, CLIPBOARD_TEXT_WRITE_MAX_BYTES, @@ -298,7 +306,7 @@ describe('AgentBrowserBridge', () => { stdinWrites.length = 0 CdpWsProxyMock.instances.length = 0 existsSyncMock.mockReturnValue(false) - readFileSyncMock.mockReturnValue(Buffer.from('')) + readScreenshotFileMock.mockReturnValue(Buffer.from('')) const wc = mockWebContents(100) webContentsFromIdMock.mockReturnValue(wc) bridge = new AgentBrowserBridge(mockBrowserManager()) @@ -763,6 +771,68 @@ describe('AgentBrowserBridge', () => { expect(snapshotIdx).toBeLessThan(clickIdx) }) + it('rejects overload instead of retaining an unbounded per-session command queue', async () => { + let releaseFirst!: () => void + const firstTurn = new Promise((resolve) => { + releaseFirst = resolve + }) + const internals = bridge as unknown as { + enqueueTargetedCommand: ( + worktreeId: string | undefined, + browserPageId: string | undefined, + execute: () => Promise, + options: { ensureSession: boolean; ensureVisible: boolean } + ) => Promise + } + const enqueue = (execute: () => Promise) => + internals.enqueueTargetedCommand(undefined, undefined, execute, { + ensureSession: false, + ensureVisible: false + }) + + const active = enqueue(() => firstTurn) + await vi.waitFor(() => + expect((bridge as unknown as { processingQueues: Set }).processingQueues.size).toBe(1) + ) + const queued = Array.from({ length: AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION }, () => + enqueue(async () => {}) + ) + + await expect(enqueue(async () => {})).rejects.toMatchObject({ code: 'browser_busy' }) + releaseFirst() + await expect(Promise.all([active, ...queued])).resolves.toHaveLength( + AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION + 1 + ) + }) + + it('bounds callers waiting for hung browser session setup before they reach the queue', async () => { + let releaseSetup!: () => void + const setup = new Promise((resolve) => { + releaseSetup = resolve + }) + const internals = bridge as unknown as { + ensureSession: () => Promise + enqueueTargetedCommand: ( + worktreeId: string | undefined, + browserPageId: string | undefined, + execute: () => Promise, + options: { ensureVisible: boolean } + ) => Promise + } + internals.ensureSession = vi.fn(() => setup) + const enqueue = () => + internals.enqueueTargetedCommand(undefined, undefined, async () => {}, { + ensureVisible: false + }) + const pending = Array.from({ length: AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION }, enqueue) + + await expect(enqueue()).rejects.toMatchObject({ code: 'browser_busy' }) + releaseSetup() + await expect(Promise.all(pending)).resolves.toHaveLength( + AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION + ) + }) + it('acquires an automation visibility lease while running snapshot commands', async () => { const lifecycleEvents: string[] = [] const restore = vi.fn(() => { @@ -1012,7 +1082,7 @@ describe('AgentBrowserBridge', () => { ) existsSyncMock.mockReturnValue(true) const screenshotBytes = Buffer.from('serialized-screenshot') - readFileSyncMock.mockReturnValue(screenshotBytes) + readScreenshotFileMock.mockReturnValue(screenshotBytes) const b = new AgentBrowserBridge( mockBrowserManager(tabs, worktrees, { @@ -1084,6 +1154,22 @@ describe('AgentBrowserBridge', () => { } }) + it('rejects an oversized screenshot file before reading or base64-copying it', () => { + existsSyncMock.mockReturnValue(true) + readScreenshotFileMock.mockImplementation(() => { + throw new Error(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + }) + + expect(() => + ( + bridge as unknown as { + readScreenshotFromResult: (raw: unknown, format?: string) => unknown + } + ).readScreenshotFromResult({ path: '/tmp/oversized.png' }, 'png') + ).toThrow(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + expect(readScreenshotFileMock).toHaveBeenCalledWith('/tmp/oversized.png') + }) + it('captures full-page screenshots directly through CDP using CSS layout bounds', async () => { vi.useFakeTimers() try { @@ -1131,6 +1217,88 @@ describe('AgentBrowserBridge', () => { } }) + it('reports oversized full-page geometry with a stable browser error code', async () => { + vi.useFakeTimers() + try { + const wc = mockWebContents(100) + wc.debugger.sendCommand.mockResolvedValueOnce({ + cssContentSize: { width: BROWSER_SCREENSHOT_MAX_DIMENSION_PX + 1, height: 1 } + }) + webContentsFromIdMock.mockReturnValue(wc) + + const screenshotPromise = bridge.fullPageScreenshot('png') + const rejection = expect(screenshotPromise).rejects.toMatchObject({ + code: 'browser_screenshot_too_large', + message: BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR + }) + await vi.advanceTimersByTimeAsync(500) + + await rejection + expect(wc.debugger.sendCommand).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('rejects a direct PDF before making an oversized base64 copy', async () => { + succeedWith(null) + const toString = vi.fn() + const wc = { + ...mockWebContents(100), + printToPDF: vi.fn(async () => ({ + length: CDP_PDF_MAX_RETAINED_BYTES + 1, + toString + })) + } + webContentsFromIdMock.mockReturnValue(wc) + + await expect(bridge.pdf()).rejects.toThrow(CDP_PDF_MEMORY_LIMIT_ERROR) + expect(toString).not.toHaveBeenCalled() + }) + + it('shares one native PDF print cap across browser sessions', async () => { + const tabs = new Map([ + ['tab-1', 1], + ['tab-2', 2], + ['tab-3', 3] + ]) + const worktrees = new Map([ + ['tab-1', 'wt-1'], + ['tab-2', 'wt-2'], + ['tab-3', 'wt-3'] + ]) + const resolvers: ((buffer: Buffer) => void)[] = [] + const webContentsById = new Map( + [1, 2, 3].map((id) => [ + id, + { + ...mockWebContents(id), + printToPDF: vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }) + ) + } + ]) + ) + webContentsFromIdMock.mockImplementation((id: number) => webContentsById.get(id) ?? null) + succeedWith(null) + const boundedBridge = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees)) + boundedBridge.setActiveTab(1, 'wt-1') + boundedBridge.setActiveTab(2, 'wt-2') + boundedBridge.setActiveTab(3, 'wt-3') + + const active = [boundedBridge.pdf('wt-1'), boundedBridge.pdf('wt-2')] + await vi.waitFor(() => expect(resolvers).toHaveLength(2)) + await expect(boundedBridge.pdf('wt-3')).rejects.toMatchObject({ code: 'browser_busy' }) + + for (const resolve of resolvers) { + resolve(Buffer.from('%PDF-bounded')) + } + await expect(Promise.all(active)).resolves.toHaveLength(2) + }) + // ── Timeout escalation ── it('destroys session after 3 consecutive timeouts', async () => { @@ -2522,6 +2690,19 @@ describe('AgentBrowserBridge', () => { expect(viewportCall).toBeUndefined() }) + it('rejects an oversized viewport before asking Chromium to allocate it', async () => { + const wc = mockWebContents(100) + webContentsFromIdMock.mockReturnValue(wc) + + await expect( + bridge.setViewport(BROWSER_SCREENSHOT_MAX_DIMENSION_PX + 1, 1, 1) + ).rejects.toMatchObject({ + code: 'browser_screenshot_too_large', + message: BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR + }) + expect(wc.debugger.sendCommand).not.toHaveBeenCalled() + }) + it('normalizes selector wait state=visible to the default supported semantics', async () => { succeedWith({ selector: 'h1', waited: 'selector' }) diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 565352d3d50..1063d7b6843 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -1,11 +1,19 @@ /* eslint-disable max-lines */ import { execFile, type ChildProcess } from 'node:child_process' -import { existsSync, accessSync, chmodSync, readFileSync, constants } from 'node:fs' +import { existsSync, accessSync, chmodSync, 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 { assertCdpPdfWithinMemoryLimit } from './cdp-print-to-pdf' +import { + assertBrowserScreenshotGeometry, + BROWSER_SCREENSHOT_BUSY_ERROR, + BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR +} from './browser-screenshot-limits' +import { readBrowserScreenshotFile } from './browser-screenshot-file-reader' +import { BROWSER_PDF_BUSY_ERROR, startBrowserPdfPrint } from './browser-pdf-admission' import { acquireElectronDebugger } from './electron-debugger-lease' import type { BrowserManager } from './browser-manager' import { BrowserError } from './cdp-bridge' @@ -59,6 +67,8 @@ const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 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 const AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION = 64 +const AGENT_BROWSER_MAX_QUEUED_COMMANDS_TOTAL = 512 type SessionState = { proxy: CdpWsProxy @@ -575,6 +585,9 @@ export class AgentBrowserBridge { private readonly sessions = new Map() private readonly commandQueues = new Map() private readonly processingQueues = new Set() + private queuedCommandCount = 0 + private readonly pendingEnqueueCounts = new Map() + private pendingEnqueueCount = 0 // 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 @@ -1483,7 +1496,17 @@ export class AgentBrowserBridge { if (!existsSync(parsed.path)) { throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`) } - const data = readFileSync(parsed.path).toString('base64') + let bytes: Buffer + try { + bytes = readBrowserScreenshotFile(parsed.path) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to read screenshot file' + if (message !== BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) { + throw new BrowserError('browser_error', message) + } + throw new BrowserError('browser_screenshot_too_large', BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + } + const data = bytes.toString('base64') return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult } @@ -1529,7 +1552,17 @@ export class AgentBrowserBridge { } return await captureFullPageScreenshot(wc, format) } catch (error) { - throw new BrowserError('browser_error', (error as Error).message) + if (error instanceof BrowserError) { + throw error + } + const message = error instanceof Error ? error.message : 'Screenshot failed' + if (message === BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) { + throw new BrowserError('browser_screenshot_too_large', message) + } + if (message === BROWSER_SCREENSHOT_BUSY_ERROR) { + throw new BrowserError('browser_busy', message) + } + throw new BrowserError('browser_error', message) } finally { restore() } @@ -1780,10 +1813,15 @@ export class AgentBrowserBridge { if (!wc) { throw new BrowserError('browser_no_tab', 'Tab is no longer available') } - const buffer = await wc.printToPDF({ + const print = startBrowserPdfPrint(wc, { printBackground: true, preferCSSPageSize: true }) + if (!print) { + throw new BrowserError('browser_busy', BROWSER_PDF_BUSY_ERROR) + } + const buffer = await print + assertCdpPdfWithinMemoryLimit(buffer) return { data: buffer.toString('base64') } }) } @@ -1861,6 +1899,14 @@ export class AgentBrowserBridge { worktreeId?: string, browserPageId?: string ): Promise { + try { + assertBrowserScreenshotGeometry(width, height, scale) + } catch (error) { + throw new BrowserError( + 'browser_screenshot_too_large', + error instanceof Error ? error.message : BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR + ) + } return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { const wc = this.getWebContents(target.webContentsId) if (!wc) { @@ -2069,12 +2115,29 @@ export class AgentBrowserBridge { const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) const sessionName = `orca-tab-${target.browserPageId}` - if (options.ensureSession !== false) { - await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) + this.acquirePendingEnqueue(sessionName) + try { + if (options.ensureSession !== false) { + await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) + } + } finally { + this.releasePendingEnqueue(sessionName) } return new Promise((resolve, reject) => { let queue = this.commandQueues.get(sessionName) + if ( + (queue?.length ?? 0) >= AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION || + this.queuedCommandCount >= AGENT_BROWSER_MAX_QUEUED_COMMANDS_TOTAL + ) { + reject( + new BrowserError( + 'browser_busy', + 'Browser command queue is full; retry after the current commands finish' + ) + ) + return + } if (!queue) { queue = [] this.commandQueues.set(sessionName, queue) @@ -2091,10 +2154,37 @@ export class AgentBrowserBridge { resolve: resolve as (value: unknown) => void, reject }) + this.queuedCommandCount += 1 this.processQueue(sessionName) }) } + private acquirePendingEnqueue(sessionName: string): void { + const pendingForSession = this.pendingEnqueueCounts.get(sessionName) ?? 0 + const queuedForSession = this.commandQueues.get(sessionName)?.length ?? 0 + if ( + pendingForSession + queuedForSession >= AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION || + this.pendingEnqueueCount + this.queuedCommandCount >= AGENT_BROWSER_MAX_QUEUED_COMMANDS_TOTAL + ) { + throw new BrowserError( + 'browser_busy', + 'Browser command queue is full; retry after the current commands finish' + ) + } + this.pendingEnqueueCounts.set(sessionName, pendingForSession + 1) + this.pendingEnqueueCount += 1 + } + + private releasePendingEnqueue(sessionName: string): void { + const remaining = (this.pendingEnqueueCounts.get(sessionName) ?? 1) - 1 + if (remaining > 0) { + this.pendingEnqueueCounts.set(sessionName, remaining) + } else { + this.pendingEnqueueCounts.delete(sessionName) + } + this.pendingEnqueueCount = Math.max(0, this.pendingEnqueueCount - 1) + } + private async executeWithVisibleTarget( sessionName: string, worktreeId: string | undefined, @@ -2159,6 +2249,7 @@ export class AgentBrowserBridge { const queue = this.commandQueues.get(sessionName) while (queue && queue.length > 0) { const cmd = queue.shift()! + this.queuedCommandCount = Math.max(0, this.queuedCommandCount - 1) try { const result = await cmd.execute() cmd.resolve(result) @@ -2456,6 +2547,7 @@ export class AgentBrowserBridge { this.commandQueues.delete(sessionName) this.processingQueues.delete(sessionName) if (queue) { + this.queuedCommandCount = Math.max(0, this.queuedCommandCount - queue.length) const err = new BrowserError( 'browser_tab_closed', 'Tab was closed while commands were queued' diff --git a/src/main/browser/browser-cookie-import.comet.test.ts b/src/main/browser/browser-cookie-import.comet.test.ts index 47e84db1607..9fde630c9da 100644 --- a/src/main/browser/browser-cookie-import.comet.test.ts +++ b/src/main/browser/browser-cookie-import.comet.test.ts @@ -1,11 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as childProcessModule from 'node:child_process' import type * as fsModule from 'node:fs' +import type * as boundedFileReaderModule from '../../shared/memory-safety/node-bounded-file-reader' -const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ - sessionFromPartitionMock: vi.fn(), - dialogShowOpenDialogMock: vi.fn() -})) +const { sessionFromPartitionMock, dialogShowOpenDialogMock, boundedLocalStateReadMock } = + vi.hoisted(() => ({ + sessionFromPartitionMock: vi.fn(), + dialogShowOpenDialogMock: vi.fn(), + boundedLocalStateReadMock: vi.fn<(filePath: string) => string>() + })) vi.mock('electron', () => ({ BrowserWindow: { fromWebContents: vi.fn() }, @@ -13,12 +16,32 @@ vi.mock('electron', () => ({ session: { fromPartition: sessionFromPartitionMock } })) +vi.mock('../../shared/memory-safety/node-bounded-file-reader', async () => { + const actual = await vi.importActual( + '../../shared/memory-safety/node-bounded-file-reader' + ) + return { + ...actual, + readNodeFileSyncWithinLimit: (filePath: string, maxBytes: number) => { + const buffer = Buffer.from(boundedLocalStateReadMock(filePath)) + if (buffer.byteLength > maxBytes) { + throw new actual.NodeFileReadTooLargeError(buffer.byteLength, maxBytes) + } + return { buffer, stats: { size: buffer.byteLength } } + } + } +}) + import { BROWSER_FAMILY_LABELS } from '../../shared/constants' function slashPath(pathValue: string): string { return pathValue.replaceAll('\\', '/') } +function mockLocalState(value: unknown): void { + boundedLocalStateReadMock.mockReturnValue(JSON.stringify(value)) +} + describe('detectInstalledBrowsers — Comet', () => { const originalPlatform = process.platform const originalHome = process.env.HOME @@ -32,6 +55,10 @@ describe('detectInstalledBrowsers — Comet', () => { vi.resetModules() Object.defineProperty(process, 'platform', { value: 'darwin' }) process.env.HOME = '/Users/test' + boundedLocalStateReadMock.mockReset() + boundedLocalStateReadMock.mockImplementation(() => { + throw new Error('ENOENT') + }) }) afterEach(() => { @@ -41,6 +68,7 @@ describe('detectInstalledBrowsers — Comet', () => { }) it('detects Comet when its data directory and Cookies DB exist', async () => { + mockLocalState({ profile: { info_cache: { Default: { name: 'Default' } } } }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -88,6 +116,15 @@ describe('detectInstalledBrowsers — Comet', () => { }) it('enumerates all Comet profiles from Local State info_cache', async () => { + mockLocalState({ + profile: { + info_cache: { + Default: { name: 'Personal' }, + 'Profile 1': { name: 'Work' }, + 'Profile 2': { name: 'Research' } + } + } + }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -130,6 +167,13 @@ describe('detectInstalledBrowsers — Comet', () => { }) it('ignores Comet profile directories that escape the browser root', async () => { + mockLocalState({ + profile: { + info_cache: { + '../Outside': { name: 'Outside' } + } + } + }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -195,6 +239,7 @@ describe('detectInstalledBrowsers — Comet', () => { }) it('skips Comet when the data directory exists but no Cookies DB is present', async () => { + mockLocalState({ profile: { info_cache: { Default: { name: 'Default' } } } }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { diff --git a/src/main/browser/browser-cookie-import.helium.test.ts b/src/main/browser/browser-cookie-import.helium.test.ts index 1c551ac004a..190b8723fd9 100644 --- a/src/main/browser/browser-cookie-import.helium.test.ts +++ b/src/main/browser/browser-cookie-import.helium.test.ts @@ -1,11 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as childProcessModule from 'node:child_process' import type * as fsModule from 'node:fs' +import type * as boundedFileReaderModule from '../../shared/memory-safety/node-bounded-file-reader' -const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ - sessionFromPartitionMock: vi.fn(), - dialogShowOpenDialogMock: vi.fn() -})) +const { sessionFromPartitionMock, dialogShowOpenDialogMock, boundedLocalStateReadMock } = + vi.hoisted(() => ({ + sessionFromPartitionMock: vi.fn(), + dialogShowOpenDialogMock: vi.fn(), + boundedLocalStateReadMock: vi.fn<(filePath: string) => string>() + })) vi.mock('electron', () => ({ BrowserWindow: { fromWebContents: vi.fn() }, @@ -13,12 +16,32 @@ vi.mock('electron', () => ({ session: { fromPartition: sessionFromPartitionMock } })) +vi.mock('../../shared/memory-safety/node-bounded-file-reader', async () => { + const actual = await vi.importActual( + '../../shared/memory-safety/node-bounded-file-reader' + ) + return { + ...actual, + readNodeFileSyncWithinLimit: (filePath: string, maxBytes: number) => { + const buffer = Buffer.from(boundedLocalStateReadMock(filePath)) + if (buffer.byteLength > maxBytes) { + throw new actual.NodeFileReadTooLargeError(buffer.byteLength, maxBytes) + } + return { buffer, stats: { size: buffer.byteLength } } + } + } +}) + import { BROWSER_FAMILY_LABELS } from '../../shared/constants' function slashPath(pathValue: string): string { return pathValue.replaceAll('\\', '/') } +function mockLocalState(value: unknown): void { + boundedLocalStateReadMock.mockReturnValue(JSON.stringify(value)) +} + describe('detectInstalledBrowsers — Helium', () => { const originalPlatform = process.platform const originalHome = process.env.HOME @@ -30,6 +53,10 @@ describe('detectInstalledBrowsers — Helium', () => { vi.resetModules() Object.defineProperty(process, 'platform', { value: 'darwin' }) process.env.HOME = '/Users/test' + boundedLocalStateReadMock.mockReset() + boundedLocalStateReadMock.mockImplementation(() => { + throw new Error('ENOENT') + }) }) afterEach(() => { @@ -39,6 +66,7 @@ describe('detectInstalledBrowsers — Helium', () => { }) it('detects Helium under its bundle-id data dir via the legacy Cookies path', async () => { + mockLocalState({ profile: { info_cache: { Default: { name: 'Default' } } } }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -93,6 +121,14 @@ describe('detectInstalledBrowsers — Helium', () => { }) it('enumerates all Helium profiles from Local State info_cache', async () => { + mockLocalState({ + profile: { + info_cache: { + Default: { name: 'Personal' }, + 'Profile 1': { name: 'Work' } + } + } + }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 27663017097..1c1f2b9c85b 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -36,6 +36,7 @@ vi.mock('electron', () => ({ import { buildChromiumCookieInsertParams, + CHROMIUM_LOCAL_STATE_MAX_BYTES, importCookiesFromFile, importCookiesFromBrowser, detectInstalledBrowsers, @@ -47,7 +48,20 @@ import { createChromiumCookieTestDatabase, encryptMacChromiumCookie } from './browser-cookie-import-test-database' -import { existsSync, writeFileSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { COOKIE_JSON_FILE_MAX_BYTES } from './browser-cookie-json-file-parser' +import { INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES } from './installed-browser-cookie-store-limits' +import { SAFARI_COOKIE_STORE_MAX_FILE_BYTES } from './safari-cookie-store-decoder' +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + truncateSync, + writeFileSync +} from 'node:fs' +import { DatabaseSync } from 'node:sqlite' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -63,6 +77,40 @@ function chromeBrowser(cookiesPath: string): DetectedBrowser { } } +function firefoxBrowser(cookiesPath: string): DetectedBrowser { + return { + family: 'firefox', + label: 'Firefox', + cookiesPath, + profiles: [{ name: 'default', directory: 'default' }], + selectedProfile: 'default' + } +} + +function createFirefoxCookieTestDatabase( + databasePath: string, + rows: { name: string; value: string; host: string }[] +): void { + const database = new DatabaseSync(databasePath) + database.exec(` + CREATE TABLE moz_cookies ( + name TEXT, + value TEXT, + host TEXT, + path TEXT, + expiry INTEGER, + isSecure INTEGER, + isHttpOnly INTEGER, + sameSite INTEGER + ) + `) + const insert = database.prepare('INSERT INTO moz_cookies VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + for (const row of rows) { + insert.run(row.name, row.value, row.host, '/', 2_000_000_000, 0, 0, 0) + } + database.close() +} + const LARGE_SAFARI_COOKIE_COUNT = 150_000 describe('summarizeCookieImportError', () => { @@ -281,6 +329,20 @@ describe('importCookiesFromFile', () => { expect(result.reason).toContain('Could not read') }) + it('rejects an oversized JSON file before reading its bytes', async () => { + const filePath = join(tmpDir, 'oversized.json') + writeFileSync(filePath, '') + truncateSync(filePath, COOKIE_JSON_FILE_MAX_BYTES + 1) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result).toEqual({ + ok: false, + reason: 'Cookie file is too large to import safely (64 MiB file limit).' + }) + expect(sessionFromPartitionMock).not.toHaveBeenCalled() + }) + it('normalizes sameSite values', async () => { const filePath = writeCookieFile([ { domain: '.test.com', name: 'a', value: '1', sameSite: 'None' }, @@ -364,6 +426,94 @@ describe('importCookiesFromBrowser Safari', () => { expect(result).toEqual({ ok: false, reason: 'All Safari cookies are expired.' }) expect(cookiesSetMock).not.toHaveBeenCalled() }) + + it('rejects an oversized Safari store before reading its bytes', async () => { + const cookiesPath = join(tmpDir, 'Cookies.binarycookies') + writeFileSync(cookiesPath, '') + truncateSync(cookiesPath, SAFARI_COOKIE_STORE_MAX_FILE_BYTES + 1) + const browser: DetectedBrowser = { + family: 'safari', + label: 'Safari', + cookiesPath, + profiles: [], + selectedProfile: 'Default' + } + + const result = await importCookiesFromBrowser(browser, 'persist:test') + + expect(result).toEqual({ + ok: false, + reason: 'Safari cookie store is too large to import safely (64 MiB file limit).' + }) + expect(sessionFromPartitionMock).not.toHaveBeenCalled() + }) +}) + +describe('importCookiesFromBrowser Firefox', () => { + let tmpDir: string + let cookiesSetMock: ReturnType + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-firefox-cookie-test-')) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReset() + sessionFromPartitionMock.mockReturnValue({ + cookies: { set: cookiesSetMock } + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('streams ordinary cookies in source order without changing the summary', async () => { + const cookiesPath = join(tmpDir, 'cookies.sqlite') + createFirefoxCookieTestDatabase(cookiesPath, [ + { name: 'first', value: 'one', host: '.first.example.com' }, + { name: 'second', value: 'two', host: '.second.example.com' } + ]) + + const result = await importCookiesFromBrowser(firefoxBrowser(cookiesPath), 'persist:test') + + expect(result).toEqual({ + ok: true, + profileId: '', + summary: { + totalCookies: 2, + importedCookies: 2, + skippedCookies: 0, + domains: ['first.example.com', 'second.example.com'] + } + }) + expect(cookiesSetMock.mock.calls.map(([cookie]) => cookie.name)).toEqual(['first', 'second']) + }) + + it('rejects oversized cookie data before iterating or mutating the target session', async () => { + const cookiesPath = join(tmpDir, 'cookies.sqlite') + const database = new DatabaseSync(cookiesPath) + database.exec(` + CREATE VIEW moz_cookies AS + SELECT + 'sid' AS name, + zeroblob(${INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES + 1}) AS value, + '.example.com' AS host, + '/' AS path, + 0 AS expiry, + 0 AS isSecure, + 0 AS isHttpOnly, + 0 AS sameSite + `) + database.close() + + const result = await importCookiesFromBrowser(firefoxBrowser(cookiesPath), 'persist:test') + + expect(result).toEqual({ + ok: false, + reason: + 'Firefox cookie store is too large to import safely (250,000-cookie and 64 MiB cookie-data limits).' + }) + expect(sessionFromPartitionMock).not.toHaveBeenCalled() + }) }) describe('importCookiesFromBrowser Chromium', () => { @@ -530,9 +680,79 @@ describe('importCookiesFromBrowser Chromium', () => { expect(result).toEqual({ ok: false, reason: 'Could not create staging cookie database.' }) expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) }) + + it('rejects oversized source data before decrypting or clearing target cookies', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + mkdirSync(join(sourceCookiesPath, '..'), { recursive: true }) + const sourceDatabase = new DatabaseSync(sourceCookiesPath) + sourceDatabase.exec(` + CREATE VIEW cookies AS + SELECT + 1 AS creation_utc, + '.example.com' AS host_key, + '' AS top_frame_site_key, + 'sid' AS name, + zeroblob(${INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES + 1}) AS value, + X'' AS encrypted_value, + '/' AS path, + 0 AS expires_utc, + 0 AS is_secure, + 0 AS is_httponly, + 0 AS samesite, + 0 AS source_scheme, + -1 AS source_port, + 0 AS last_update_utc, + 0 AS has_cross_site_ancestor + `) + sourceDatabase.close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result).toEqual({ + ok: false, + reason: + 'Google Chrome cookie store is too large to import safely (250,000-cookie and 64 MiB cookie-data limits).' + }) + expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + }) }) describe('detectInstalledBrowsers', () => { + it('falls back safely when Chromium Local State exceeds its read cap', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-browser-detection-bounds-')) + const originalConfigHome = process.env.XDG_CONFIG_HOME + const originalPlatform = process.platform + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + process.env.XDG_CONFIG_HOME = root + const browserRoot = join(root, 'google-chrome') + mkdirSync(join(browserRoot, 'Default'), { recursive: true }) + writeFileSync(join(browserRoot, 'Default', 'Cookies'), '') + writeFileSync(join(browserRoot, 'Local State'), '') + truncateSync(join(browserRoot, 'Local State'), CHROMIUM_LOCAL_STATE_MAX_BYTES + 1) + + const chrome = detectInstalledBrowsers().find((browser) => browser.family === 'chrome') + + expect(chrome?.profiles).toEqual([{ name: 'Default', directory: 'Default' }]) + expect(chrome?.selectedProfile).toBe('Default') + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + if (originalConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalConfigHome + } + rmSync(root, { recursive: true, force: true }) + } + }) + it('returns an array of detected browsers', () => { const browsers = detectInstalledBrowsers() expect(Array.isArray(browsers)).toBe(true) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 7bcfd4ea247..31a3a0c5254 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -8,12 +8,10 @@ import { existsSync, mkdtempSync, mkdirSync, - readFileSync, - readdirSync, + opendirSync, 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' @@ -73,13 +71,42 @@ import type { BrowserCookieImportSummary, BrowserSessionProfileSource } from '../../shared/types' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit, + readNodeFileSyncWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' import { browserSessionRegistry } from './browser-session-registry' import { setupClientHintsOverride } from './browser-session-ua' +import { + COOKIE_JSON_FILE_MAX_BYTES, + COOKIE_JSON_FILE_MAX_DEPTH, + COOKIE_JSON_FILE_MAX_ENTRIES, + COOKIE_JSON_FILE_MAX_RETAINED_BYTES, + CookieJsonFileFormatError, + CookieJsonFileLimitError, + enforceCookieJsonRetainedBytes, + visitCookieJsonFileObjects +} from './browser-cookie-json-file-parser' import { createChromiumCookieSnapshot, type ChromiumCookieSnapshot } from './chromium-cookie-snapshot' import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { + assertInstalledBrowserCookieStoreWithinLimits, + InstalledBrowserCookieStoreLimitError, + installedBrowserCookieStoreLimitReason +} from './installed-browser-cookie-store-limits' +import { + decodeSafariCookieStore, + SAFARI_COOKIE_STORE_MAX_COOKIES, + SAFARI_COOKIE_STORE_MAX_FILE_BYTES, + SAFARI_COOKIE_STORE_MAX_PAGES, + SAFARI_COOKIE_STORE_MAX_PARSED_BYTES, + SafariCookieStoreLimitError, + removeExpiredSafariCookiesInPlace +} from './safari-cookie-store-decoder' // --------------------------------------------------------------------------- // Browser detection @@ -90,6 +117,10 @@ export type BrowserProfile = { directory: string } +export const CHROMIUM_LOCAL_STATE_MAX_BYTES = 16 * 1024 * 1024 +const BROWSER_PROFILE_SCAN_MAX_ENTRIES = 10_000 +const BROWSER_PROFILE_RETAINED_NAME_MAX_BYTES = 4 * 1024 * 1024 + export type DetectedBrowser = { family: BrowserSessionProfileSource['browserFamily'] label: string @@ -210,19 +241,38 @@ function discoverProfiles(browserRoot: string): BrowserProfile[] { if (!existsSync(localStatePath)) { return [{ name: 'Default', directory: 'Default' }] } - const raw = readFileSync(localStatePath, 'utf-8') - const localState = JSON.parse(raw) + const localState = JSON.parse( + readNodeFileSyncWithinLimit(localStatePath, CHROMIUM_LOCAL_STATE_MAX_BYTES).buffer.toString( + 'utf8' + ) + ) 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)) { + let retainedNameBytes = 0 + let visitedEntries = 0 + for (const dir in infoCache) { + if (!Object.hasOwn(infoCache, dir)) { + continue + } + visitedEntries++ + if (visitedEntries > BROWSER_PROFILE_SCAN_MAX_ENTRIES) { + return [{ name: 'Default', directory: 'Default' }] + } // Why: Local State is external metadata, but profile dirs become path segments. if (!isSafeBrowserProfileDirectory(dir)) { continue } - const profileName = (info as { name?: string })?.name ?? dir + const info = (infoCache as Record)[dir] + const configuredName = + info && typeof info === 'object' ? (info as { name?: unknown }).name : undefined + const profileName = typeof configuredName === 'string' ? configuredName : dir + retainedNameBytes += Buffer.byteLength(dir, 'utf8') + Buffer.byteLength(profileName, 'utf8') + if (retainedNameBytes > BROWSER_PROFILE_RETAINED_NAME_MAX_BYTES) { + return [{ name: 'Default', directory: 'Default' }] + } profiles.push({ name: profileName, directory: dir }) } return profiles.length > 0 ? profiles : [{ name: 'Default', directory: 'Default' }] @@ -257,9 +307,30 @@ function discoverFirefoxProfiles(): BrowserProfile[] { if (!existsSync(profilesRoot)) { return [] } - const entries = readdirSync(profilesRoot, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name) + const entries: string[] = [] + let retainedNameBytes = 0 + const directory = opendirSync(profilesRoot) + try { + for (let visitedEntries = 0; ; visitedEntries++) { + const entry = directory.readSync() + if (!entry) { + break + } + if (visitedEntries >= BROWSER_PROFILE_SCAN_MAX_ENTRIES) { + return [] + } + if (!entry.isDirectory()) { + continue + } + retainedNameBytes += Buffer.byteLength(entry.name, 'utf8') + if (retainedNameBytes > BROWSER_PROFILE_RETAINED_NAME_MAX_BYTES) { + return [] + } + entries.push(entry.name) + } + } finally { + directory.closeSync() + } // 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')) { @@ -310,8 +381,6 @@ function detectFirefox(): DetectedBrowser | null { // Safari detection // --------------------------------------------------------------------------- -const MAC_EPOCH_DELTA = 978_307_200 - function detectSafari(): DetectedBrowser | null { if (process.platform !== 'darwin') { return null @@ -645,41 +714,62 @@ export async function importCookiesFromFile( ): Promise { let rawContent: string try { - rawContent = await readFile(filePath, 'utf-8') - } catch { + rawContent = ( + await readNodeFileWithinLimit(filePath, COOKIE_JSON_FILE_MAX_BYTES) + ).buffer.toString('utf8') + } catch (err) { + if (err instanceof NodeFileReadTooLargeError) { + return { + ok: false, + reason: `Cookie file is too large to import safely (${COOKIE_JSON_FILE_MAX_BYTES / 1024 / 1024} MiB file limit).` + } + } return { ok: false, reason: 'Could not read the selected file.' } } - let parsed: unknown + const validated: ValidatedCookie[] = [] + let totalEntries = 0 + let retainedBytes = 0 try { - parsed = JSON.parse(rawContent) - } catch { + totalEntries = visitCookieJsonFileObjects(rawContent, (entry) => { + const cookie = validateCookieEntry(entry as RawCookieEntry) + if (!cookie) { + return + } + retainedBytes += + Buffer.byteLength(cookie.url) + + Buffer.byteLength(cookie.name) + + Buffer.byteLength(cookie.value) + + Buffer.byteLength(cookie.domain) + + Buffer.byteLength(cookie.path) + enforceCookieJsonRetainedBytes(retainedBytes) + validated.push(cookie) + }) + } catch (err) { + if (err instanceof CookieJsonFileFormatError) { + return err.kind === 'root' + ? { ok: false, reason: 'Expected a JSON array of cookie objects.' } + : { ok: false, reason: 'File is not valid JSON.' } + } + if (err instanceof CookieJsonFileLimitError) { + const limit = { + depth: `${COOKIE_JSON_FILE_MAX_DEPTH} levels of nesting`, + entries: `${COOKIE_JSON_FILE_MAX_ENTRIES.toLocaleString('en-US')} entries`, + 'retained-bytes': `${COOKIE_JSON_FILE_MAX_RETAINED_BYTES / 1024 / 1024} MiB parsed cookie data` + }[err.kind] + return { + ok: false, + reason: `Cookie file is too large to import safely (${limit} limit).` + } + } 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) { + if (totalEntries === 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++ - } - } - + const skipped = totalEntries - validated.length if (validated.length === 0) { return { ok: false, @@ -687,7 +777,7 @@ export async function importCookiesFromFile( } } - return importValidatedCookies(validated, parsed.length, targetPartition) + return importValidatedCookies(validated, totalEntries, targetPartition) } // --------------------------------------------------------------------------- @@ -986,8 +1076,11 @@ function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult } try { - const raw = readFileSync(localStatePath, 'utf-8') - const localState = JSON.parse(raw) + const localState = JSON.parse( + readNodeFileSyncWithinLimit(localStatePath, CHROMIUM_LOCAL_STATE_MAX_BYTES).buffer.toString( + 'utf8' + ) + ) const encryptedKeyB64 = localState?.os_crypt?.encrypted_key if (typeof encryptedKeyB64 !== 'string') { return null @@ -1103,144 +1196,6 @@ function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | 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 - } -} - -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 // --------------------------------------------------------------------------- @@ -1274,8 +1229,9 @@ async function importCookiesFromFirefox( } } + let db: InstanceType | null = null try { - const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) + db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) type FirefoxRow = { name: string value: string @@ -1286,18 +1242,37 @@ async function importCookiesFromFirefox( isHttpOnly: number sameSite: number } + const sourceStats = db + .prepare( + `SELECT + COUNT(*) AS cookie_count, + COALESCE(SUM( + length(CAST(COALESCE(name, '') AS BLOB)) + + length(CAST(COALESCE(value, '') AS BLOB)) + + length(CAST(COALESCE(host, '') AS BLOB)) + + length(CAST(COALESCE(path, '') AS BLOB)) + ), 0) AS cookie_bytes + FROM moz_cookies` + ) + .get() as { cookie_count: number | bigint; cookie_bytes: number | bigint } + const totalRows = assertInstalledBrowserCookieStoreWithinLimits( + sourceStats.cookie_count, + sourceStats.cookie_bytes + ) + + diag(` Firefox source has ${totalRows} cookies`) + if (totalRows === 0) { + db.close() + db = null + rmSync(tmpDir, { recursive: true, force: true }) + return { ok: false, reason: 'No cookies found in Firefox.' } + } + const rows = db .prepare( 'SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite 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.' } - } + .iterate() as IterableIterator const now = Math.floor(Date.now() / 1000) const validated: ValidatedCookie[] = [] @@ -1328,6 +1303,8 @@ async function importCookiesFromFirefox( expirationDate: row.expiry > 0 ? row.expiry : undefined }) } + db.close() + db = null rmSync(tmpDir, { recursive: true, force: true }) @@ -1335,10 +1312,18 @@ async function importCookiesFromFirefox( return { ok: false, reason: 'No valid cookies found in Firefox.' } } - return importValidatedCookies(validated, rows.length, targetPartition) + return importValidatedCookies(validated, totalRows, targetPartition) } catch (err) { + try { + db?.close() + } catch { + /* best-effort */ + } rmSync(tmpDir, { recursive: true, force: true }) diag(` Firefox import failed: ${err}`) + if (err instanceof InstalledBrowserCookieStoreLimitError) { + return { ok: false, reason: installedBrowserCookieStoreLimitReason(browser.label) } + } return { ok: false, reason: 'Could not import cookies from Firefox. Try closing Firefox first.' @@ -1350,6 +1335,17 @@ async function importCookiesFromFirefox( // Safari import // --------------------------------------------------------------------------- +function safariCookieStoreLimitReason(error: SafariCookieStoreLimitError): string { + switch (error.kind) { + case 'pages': + return `Safari cookie store is too large to import safely (${SAFARI_COOKIE_STORE_MAX_PAGES.toLocaleString('en-US')}-page limit).` + case 'cookies': + return `Safari cookie store is too large to import safely (${SAFARI_COOKIE_STORE_MAX_COOKIES.toLocaleString('en-US')}-cookie limit).` + case 'parsed-bytes': + return `Safari cookie store is too large to import safely (${SAFARI_COOKIE_STORE_MAX_PARSED_BYTES / 1024 / 1024} MiB parsed cookie-data limit).` + } +} + async function importCookiesFromSafari( browser: DetectedBrowser, targetPartition: string @@ -1358,9 +1354,18 @@ async function importCookiesFromSafari( let data: Buffer try { - data = readFileSync(browser.cookiesPath) + data = readNodeFileSyncWithinLimit( + browser.cookiesPath, + SAFARI_COOKIE_STORE_MAX_FILE_BYTES + ).buffer } catch (err) { diag(` Safari read failed: ${err}`) + if (err instanceof NodeFileReadTooLargeError) { + return { + ok: false, + reason: `Safari cookie store is too large to import safely (${SAFARI_COOKIE_STORE_MAX_FILE_BYTES / 1024 / 1024} MiB file limit).` + } + } // 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' @@ -1375,23 +1380,25 @@ async function importCookiesFromSafari( } try { - const cookies = decodeSafariBinaryCookies(data) - diag(` Safari source has ${cookies.length} cookies`) + const decoded = decodeSafariCookieStore(data) + diag(` Safari source has ${decoded.totalCookies} cookies`) - if (cookies.length === 0) { + if (decoded.totalCookies === 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) { + removeExpiredSafariCookiesInPlace(decoded.cookies, now) + if (decoded.cookies.length === 0) { return { ok: false, reason: 'All Safari cookies are expired.' } } - return importValidatedCookies(valid, cookies.length, targetPartition) + return importValidatedCookies(decoded.cookies, decoded.totalCookies, targetPartition) } catch (err) { diag(` Safari import failed: ${err}`) + if (err instanceof SafariCookieStoreLimitError) { + return { ok: false, reason: safariCookieStoreLimitReason(err) } + } return { ok: false, reason: 'Could not import cookies from Safari.' } } } @@ -1498,16 +1505,30 @@ export async function importCookiesFromBrowser( stagingDb.exec('DELETE FROM cookies') - const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< - string, - unknown - >[] - sourceDb.close() - sourceDb = null + const sourceStats = sourceDb + .prepare( + `SELECT + COUNT(*) AS cookie_count, + COALESCE(SUM( + length(CAST(COALESCE(host_key, '') AS BLOB)) + + length(CAST(COALESCE(name, '') AS BLOB)) + + length(CAST(COALESCE(value, '') AS BLOB)) + + length(COALESCE(encrypted_value, X'')) + + length(CAST(COALESCE(path, '') AS BLOB)) + ), 0) AS cookie_bytes + FROM cookies` + ) + .get() as { cookie_count: number | bigint; cookie_bytes: number | bigint } + const totalSourceRows = assertInstalledBrowserCookieStoreWithinLimits( + sourceStats.cookie_count, + sourceStats.cookie_bytes + ) - diag(` source has ${sourceRows.length} cookies`) + diag(` source has ${totalSourceRows} cookies`) - if (sourceRows.length === 0) { + if (totalSourceRows === 0) { + sourceDb.close() + sourceDb = null stagingDb.close() stagingDb = null try { @@ -1518,14 +1539,16 @@ export async function importCookiesFromBrowser( return { ok: false, reason: `No cookies found in ${browser.label}.` } } - const needsSourceKey = sourceRows.some((sourceRow) => { - const encRaw = sourceRow.encrypted_value - return encRaw instanceof Uint8Array && encRaw.length > 0 - }) + const needsSourceKey = + sourceDb + .prepare('SELECT 1 AS needed FROM cookies WHERE length(encrypted_value) > 0 LIMIT 1') + .get() !== undefined const sourceKey = needsSourceKey ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) : null if (needsSourceKey && !sourceKey) { + sourceDb.close() + sourceDb = null stagingDb.close() stagingDb = null // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. @@ -1564,7 +1587,6 @@ export async function importCookiesFromBrowser( const domainSet = new Set() type DecryptedCookie = { - decryptedValue: Buffer value: string domain: string name: string @@ -1584,6 +1606,9 @@ export async function importCookiesFromBrowser( stagingDb.exec('BEGIN TRANSACTION') + const sourceRows = sourceDb + .prepare('SELECT * FROM cookies ORDER BY rowid') + .iterate() as IterableIterator> for (const sourceRow of sourceRows) { 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. @@ -1626,7 +1651,6 @@ export async function importCookiesFromBrowser( const value = decryptedValue.toString('latin1') decryptedCookies.push({ - decryptedValue, value, domain, name, @@ -1641,6 +1665,8 @@ export async function importCookiesFromBrowser( insertStmt.run(...params) imported++ } + sourceDb.close() + sourceDb = null diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`) stagingDb.exec('COMMIT') @@ -1706,7 +1732,7 @@ export async function importCookiesFromBrowser( } const summary: BrowserCookieImportSummary = { - totalCookies: sourceRows.length, + totalCookies: totalSourceRows, importedCookies: imported, skippedCookies: skipped, domains: [...domainSet].sort() @@ -1731,6 +1757,9 @@ export async function importCookiesFromBrowser( /* may not exist yet */ } diag(` SQLite import failed: ${err}`) + if (err instanceof InstalledBrowserCookieStoreLimitError) { + return { ok: false, reason: installedBrowserCookieStoreLimitReason(browser.label) } + } return { ok: false, reason: reasonWithDiagLog( diff --git a/src/main/browser/browser-cookie-json-file-parser.test.ts b/src/main/browser/browser-cookie-json-file-parser.test.ts new file mode 100644 index 00000000000..40ad84e35ee --- /dev/null +++ b/src/main/browser/browser-cookie-json-file-parser.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import { + COOKIE_JSON_FILE_MAX_BYTES, + COOKIE_JSON_FILE_MAX_DEPTH, + COOKIE_JSON_FILE_MAX_ENTRIES, + COOKIE_JSON_FILE_MAX_RETAINED_BYTES, + CookieJsonFileFormatError, + CookieJsonFileLimitError, + enforceCookieJsonRetainedBytes, + visitCookieJsonFileObjects +} from './browser-cookie-json-file-parser' + +describe('visitCookieJsonFileObjects', () => { + it('visits top-level objects in order and counts every entry', () => { + const onObject = vi.fn() + const total = visitCookieJsonFileObjects( + '[{"name":"first"}, 42, ["ignored"], {"name":"last","nested":{"x":1}}]', + onObject + ) + + expect(total).toBe(4) + expect(onObject.mock.calls.map(([entry]) => entry)).toEqual([ + { name: 'first' }, + { name: 'last' } + ]) + }) + + it('accepts the exact entry boundary and rejects one over it', () => { + expect(visitCookieJsonFileObjects('[{},{}]', () => undefined, 2)).toBe(2) + expect(() => visitCookieJsonFileObjects('[{},{},{}]', () => undefined, 2)).toThrow( + expect.objectContaining({ + name: CookieJsonFileLimitError.name, + kind: 'entries', + observed: 3, + limit: 2 + }) + ) + }) + + it('accepts the retained-byte boundary and rejects one byte over it', () => { + expect(() => enforceCookieJsonRetainedBytes(COOKIE_JSON_FILE_MAX_RETAINED_BYTES)).not.toThrow() + expect(() => enforceCookieJsonRetainedBytes(COOKIE_JSON_FILE_MAX_RETAINED_BYTES + 1)).toThrow( + expect.objectContaining({ + name: CookieJsonFileLimitError.name, + kind: 'retained-bytes', + observed: COOKIE_JSON_FILE_MAX_RETAINED_BYTES + 1 + }) + ) + }) + + it('accepts the nesting boundary and rejects one level over it', () => { + expect( + visitCookieJsonFileObjects(`${'['.repeat(4)}0${']'.repeat(4)}`, () => undefined, 10, 4) + ).toBe(1) + expect(() => + visitCookieJsonFileObjects(`${'['.repeat(5)}0${']'.repeat(5)}`, () => undefined, 10, 4) + ).toThrow( + expect.objectContaining({ + name: CookieJsonFileLimitError.name, + kind: 'depth', + observed: 5, + limit: 4 + }) + ) + }) + + it.each([ + { raw: '{"name":"not-an-array"}', kind: 'root' }, + { raw: '[{"name":}]', kind: 'syntax' }, + { raw: '[{"name":"cookie"},]', kind: 'syntax' }, + { raw: '[/* comment */ {"name":"cookie"}]', kind: 'syntax' } + ])('rejects $kind input without returning partial data', ({ raw, kind }) => { + expect(() => visitCookieJsonFileObjects(raw, () => undefined)).toThrow( + expect.objectContaining({ name: CookieJsonFileFormatError.name, kind }) + ) + }) + + it('publishes the production file, entry, and retained-data limits', () => { + expect({ + fileBytes: COOKIE_JSON_FILE_MAX_BYTES, + depth: COOKIE_JSON_FILE_MAX_DEPTH, + entries: COOKIE_JSON_FILE_MAX_ENTRIES, + retainedBytes: COOKIE_JSON_FILE_MAX_RETAINED_BYTES + }).toEqual({ + fileBytes: 64 * 1024 * 1024, + depth: 128, + entries: 250_000, + retainedBytes: 64 * 1024 * 1024 + }) + }) +}) diff --git a/src/main/browser/browser-cookie-json-file-parser.ts b/src/main/browser/browser-cookie-json-file-parser.ts new file mode 100644 index 00000000000..5c33bddecb8 --- /dev/null +++ b/src/main/browser/browser-cookie-json-file-parser.ts @@ -0,0 +1,184 @@ +import { visit, type JSONPath } from 'jsonc-parser' + +export const COOKIE_JSON_FILE_MAX_BYTES = 64 * 1024 * 1024 +export const COOKIE_JSON_FILE_MAX_ENTRIES = 250_000 +export const COOKIE_JSON_FILE_MAX_DEPTH = 128 +export const COOKIE_JSON_FILE_MAX_RETAINED_BYTES = 64 * 1024 * 1024 + +export type CookieJsonFileLimitKind = 'depth' | 'entries' | 'retained-bytes' +export type CookieJsonFileFormatKind = 'syntax' | 'root' + +export class CookieJsonFileLimitError extends Error { + constructor( + readonly kind: CookieJsonFileLimitKind, + readonly observed: number, + readonly limit: number + ) { + super(`Cookie JSON file exceeds the ${kind} limit`) + this.name = 'CookieJsonFileLimitError' + } +} + +export class CookieJsonFileFormatError extends Error { + constructor(readonly kind: CookieJsonFileFormatKind) { + super(`Cookie JSON file has invalid ${kind}`) + this.name = 'CookieJsonFileFormatError' + } +} + +type Container = { + kind: 'array' | 'object' + topLevelEntry: boolean +} + +const COOKIE_PROPERTY_NAMES = new Set([ + 'domain', + 'name', + 'value', + 'path', + 'secure', + 'httpOnly', + 'sameSite', + 'expirationDate' +]) + +export function visitCookieJsonFileObjects( + rawContent: string, + onObject: (entry: Record) => void, + maxEntries = COOKIE_JSON_FILE_MAX_ENTRIES, + maxDepth = COOKIE_JSON_FILE_MAX_DEPTH +): number { + enforceJsonNestingDepth(rawContent, maxDepth) + const containers: Container[] = [] + let rootKind: 'array' | 'other' | null = null + let currentEntry: Record | null = null + let entryCount = 0 + let syntaxError = false + + const countEntry = (): void => { + entryCount += 1 + if (entryCount > maxEntries) { + throw new CookieJsonFileLimitError('entries', entryCount, maxEntries) + } + } + const isTopLevelEntry = (path: JSONPath): boolean => rootKind === 'array' && path.length === 1 + const setDirectProperty = (path: JSONPath, value: unknown): void => { + const property = path[1] + if ( + currentEntry && + path.length === 2 && + typeof property === 'string' && + COOKIE_PROPERTY_NAMES.has(property) + ) { + currentEntry[property] = value + } + } + + // Why: retain only fields validation uses instead of materializing the entire external JSON tree. + visit( + rawContent, + { + onArrayBegin: (_offset, _length, _line, _character, pathSupplier) => { + const path = pathSupplier() + if (path.length === 0) { + rootKind = 'array' + } + const topLevelEntry = isTopLevelEntry(path) + if (topLevelEntry) { + countEntry() + } else { + setDirectProperty(path, undefined) + } + containers.push({ kind: 'array', topLevelEntry }) + }, + onArrayEnd: () => { + containers.pop() + }, + onObjectBegin: (_offset, _length, _line, _character, pathSupplier) => { + const path = pathSupplier() + if (path.length === 0) { + rootKind = 'other' + } + const topLevelEntry = isTopLevelEntry(path) + if (topLevelEntry) { + countEntry() + currentEntry = {} + } else { + setDirectProperty(path, undefined) + } + containers.push({ kind: 'object', topLevelEntry }) + }, + onObjectEnd: () => { + const container = containers.pop() + if (!container || container.kind !== 'object' || !container.topLevelEntry) { + return + } + if (currentEntry) { + onObject(currentEntry) + } + currentEntry = null + }, + onLiteralValue: (value, _offset, _length, _line, _character, pathSupplier) => { + const path = pathSupplier() + if (path.length === 0) { + rootKind = 'other' + } else if (isTopLevelEntry(path)) { + countEntry() + } else { + setDirectProperty(path, value) + } + }, + onError: () => { + syntaxError = true + } + }, + { disallowComments: true, allowTrailingComma: false, allowEmptyContent: false } + ) + + if (syntaxError) { + throw new CookieJsonFileFormatError('syntax') + } + if (rootKind !== 'array') { + throw new CookieJsonFileFormatError('root') + } + return entryCount +} + +function enforceJsonNestingDepth(rawContent: string, maxDepth: number): void { + let depth = 0 + let inString = false + let escaped = false + for (let index = 0; index < rawContent.length; index += 1) { + const character = rawContent[index] + if (inString) { + if (escaped) { + escaped = false + } else if (character === '\\') { + escaped = true + } else if (character === '"') { + inString = false + } + continue + } + if (character === '"') { + inString = true + } else if (character === '[' || character === '{') { + depth += 1 + if (depth > maxDepth) { + throw new CookieJsonFileLimitError('depth', depth, maxDepth) + } + } else if (character === ']' || character === '}') { + depth = Math.max(0, depth - 1) + } + } +} + +export function enforceCookieJsonRetainedBytes(observed: number): void { + if (observed > COOKIE_JSON_FILE_MAX_RETAINED_BYTES) { + throw new CookieJsonFileLimitError( + 'retained-bytes', + observed, + COOKIE_JSON_FILE_MAX_RETAINED_BYTES + ) + } +} diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 286f172a645..f8a1061b9ad 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -54,7 +54,7 @@ vi.mock('./popup-origin-bar-window', () => ({ openPopupWithOriginBar: openPopupWithOriginBarMock })) -import { browserManager } from './browser-manager' +import { MAX_ACTIVE_BROWSER_DOWNLOADS, browserManager } from './browser-manager' describe('browserManager', () => { const rendererWebContentsId = 5001 @@ -1927,6 +1927,24 @@ describe('browserManager', () => { ) }) + it('cancels excess concurrent downloads before retaining another item', () => { + const item = createDownloadItem() + const managerState = browserManager as unknown as { downloadsById: Map } + for (let index = 0; index < MAX_ACTIVE_BROWSER_DOWNLOADS; index += 1) { + managerState.downloadsById.set(`active-${index}`, {}) + } + + try { + browserManager.handleGuestWillDownload({ guestWebContentsId: 999, item }) + + expect(item.cancel).toHaveBeenCalledTimes(1) + expect(item.setSavePath).not.toHaveBeenCalled() + expect(managerState.downloadsById.size).toBe(MAX_ACTIVE_BROWSER_DOWNLOADS) + } finally { + managerState.downloadsById.clear() + } + }) + it('flushes started and terminal snapshots for downloads that finish before registration', () => { const rendererSendMock = vi.fn() const guest = { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index bd0e5698cd8..0c7856d070f 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -198,6 +198,8 @@ type ActiveDownload = { cleanup: (() => void) | null } +export const MAX_ACTIVE_BROWSER_DOWNLOADS = 64 + function safeOrigin(rawUrl: string): string { const external = normalizeExternalBrowserUrl(rawUrl) const urlToParse = external ?? rawUrl @@ -1257,6 +1259,14 @@ export class BrowserManager { handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { const { guestWebContentsId, item } = args + if (this.downloadsById.size >= MAX_ACTIVE_BROWSER_DOWNLOADS) { + try { + item.cancel() + } catch { + // Why: rejecting excess admission must remain safe if Chromium already finalized the item. + } + return + } const downloadId = randomUUID() const requestedFilename = (() => { try { diff --git a/src/main/browser/browser-pdf-admission.test.ts b/src/main/browser/browser-pdf-admission.test.ts new file mode 100644 index 00000000000..f68771f9907 --- /dev/null +++ b/src/main/browser/browser-pdf-admission.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { + acquireBrowserPdfAdmission, + BROWSER_PDF_MAX_CONCURRENT_PRINTS, + startBrowserPdfPrint +} from './browser-pdf-admission' + +function createWebContents() { + return { printToPDF: vi.fn<() => Promise>() } +} + +describe('browser PDF admission', () => { + it('caps native prints process-wide and releases only after native settlement', async () => { + const resolvers: ((data: Buffer) => void)[] = [] + const webContents = createWebContents() + webContents.printToPDF.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }) + ) + + const active = Array.from({ length: BROWSER_PDF_MAX_CONCURRENT_PRINTS }, () => + startBrowserPdfPrint(webContents as never, {}) + ) + expect(active.every(Boolean)).toBe(true) + expect(startBrowserPdfPrint(createWebContents() as never, {})).toBeNull() + + resolvers[0]!(Buffer.from('first')) + await Promise.resolve() + const nextWebContents = createWebContents() + nextWebContents.printToPDF.mockResolvedValue(Buffer.from('next')) + await expect(startBrowserPdfPrint(nextWebContents as never, {})).resolves.toEqual( + Buffer.from('next') + ) + + resolvers[1]!(Buffer.from('second')) + await Promise.all(active) + }) + + it('lets pre-render work release unused admission', () => { + const first = acquireBrowserPdfAdmission() + const second = acquireBrowserPdfAdmission() + expect(first).not.toBeNull() + expect(second).not.toBeNull() + expect(acquireBrowserPdfAdmission()).toBeNull() + + first!.releaseIfIdle() + const replacement = acquireBrowserPdfAdmission() + expect(replacement).not.toBeNull() + second!.releaseIfIdle() + replacement!.releaseIfIdle() + }) + + it('releases process-wide print slots after synchronous and asynchronous failures', async () => { + const synchronous = createWebContents() + synchronous.printToPDF.mockImplementation(() => { + throw new Error('sync print failure') + }) + await expect(startBrowserPdfPrint(synchronous as never, {})).rejects.toThrow( + 'sync print failure' + ) + + const asynchronous = createWebContents() + asynchronous.printToPDF.mockRejectedValue(new Error('async print failure')) + await expect(startBrowserPdfPrint(asynchronous as never, {})).rejects.toThrow( + 'async print failure' + ) + + const replacement = createWebContents() + replacement.printToPDF.mockResolvedValue(Buffer.from('replacement')) + await expect(startBrowserPdfPrint(replacement as never, {})).resolves.toEqual( + Buffer.from('replacement') + ) + }) +}) diff --git a/src/main/browser/browser-pdf-admission.ts b/src/main/browser/browser-pdf-admission.ts new file mode 100644 index 00000000000..8ef66e39415 --- /dev/null +++ b/src/main/browser/browser-pdf-admission.ts @@ -0,0 +1,59 @@ +import type { PrintToPDFOptions, WebContents } from 'electron' + +export const BROWSER_PDF_MAX_CONCURRENT_PRINTS = 2 +export const BROWSER_PDF_BUSY_ERROR = 'Too many PDF print requests are already running' + +let activePrints = 0 + +export type BrowserPdfAdmission = { + print(webContents: WebContents, options: PrintToPDFOptions): Promise + releaseIfIdle(): void +} + +export function acquireBrowserPdfAdmission(): BrowserPdfAdmission | null { + if (activePrints >= BROWSER_PDF_MAX_CONCURRENT_PRINTS) { + return null + } + activePrints += 1 + let printStarted = false + let released = false + const release = (): void => { + if (released) { + return + } + released = true + activePrints = Math.max(0, activePrints - 1) + } + + return { + print(webContents, options) { + if (printStarted) { + return Promise.reject(new Error('PDF admission has already started a print')) + } + printStarted = true + let print: Promise + try { + print = webContents.printToPDF(options) + } catch (error) { + release() + return Promise.reject(error) + } + // Why: native PDF work cannot be cancelled when its caller times out or disconnects. + void print.then(release, release) + return print + }, + releaseIfIdle() { + if (!printStarted) { + release() + } + } + } +} + +export function startBrowserPdfPrint( + webContents: WebContents, + options: PrintToPDFOptions +): Promise | null { + const admission = acquireBrowserPdfAdmission() + return admission?.print(webContents, options) ?? null +} diff --git a/src/main/browser/browser-screencast-stream.test.ts b/src/main/browser/browser-screencast-stream.test.ts index d1fc838807d..7929ba611a3 100644 --- a/src/main/browser/browser-screencast-stream.test.ts +++ b/src/main/browser/browser-screencast-stream.test.ts @@ -3,7 +3,11 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { decodeBrowserScreencastFrame } from '../../shared/browser-screencast-protocol' -import { startBrowserScreencast } from './browser-screencast-stream' +import { + decodeBrowserScreencastImage, + MAX_BROWSER_SCREENCAST_BASE64_CHARACTERS, + startBrowserScreencast +} from './browser-screencast-stream' function createMockWebContents() { let attached = false @@ -57,6 +61,16 @@ function jpegWithSize(width: number, height: number): Buffer { } describe('startBrowserScreencast', () => { + it('rejects oversized CDP image text before base64 decoding', () => { + const decode = vi.spyOn(Buffer, 'from') + + expect( + decodeBrowserScreencastImage('A'.repeat(MAX_BROWSER_SCREENCAST_BASE64_CHARACTERS + 1)) + ).toBeNull() + expect(decode).not.toHaveBeenCalled() + decode.mockRestore() + }) + it('emits an initial captured frame before CDP produces screencast events', async () => { const webContents = createMockWebContents() const firstFrame = Buffer.from('first-frame') diff --git a/src/main/browser/browser-screencast-stream.ts b/src/main/browser/browser-screencast-stream.ts index ad2e1e27e62..19f9d57d423 100644 --- a/src/main/browser/browser-screencast-stream.ts +++ b/src/main/browser/browser-screencast-stream.ts @@ -10,9 +10,12 @@ import { import { BrowserError } from './cdp-bridge' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' import { readBrowserScreencastImageSize } from './browser-screencast-image-size' +import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/remote-runtime-memory-limits' const DEBUGGER_COMMAND_TIMEOUT_MS = 8_000 const BACKPRESSURE_RETRY_MS = 50 +export const MAX_BROWSER_SCREENCAST_BASE64_CHARACTERS = + Math.ceil(REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES / 3) * 4 export type BrowserScreencastOptions = { format: BrowserScreencastFormat @@ -452,7 +455,10 @@ export async function startBrowserScreencast( } try { - const image = new Uint8Array(Buffer.from(data, 'base64')) + const image = decodeBrowserScreencastImage(data) + if (!image) { + throw new Error('Browser screencast frame is too large') + } // Why: image dimension parsing happens for every live frame; share the // result between stale-frame rejection and metadata enrichment. const imageSize = readBrowserScreencastImageSize(image, options.format) @@ -558,7 +564,10 @@ export async function startBrowserScreencast( if (!data) { return } - image = new Uint8Array(Buffer.from(data, 'base64')) + image = decodeBrowserScreencastImage(data) + if (!image) { + return + } } if (isSnapshotStale(initialOnly, generation)) { return @@ -632,3 +641,11 @@ export async function startBrowserScreencast( done } } + +export function decodeBrowserScreencastImage(data: string): Uint8Array | null { + if (data.length > MAX_BROWSER_SCREENCAST_BASE64_CHARACTERS) { + return null + } + const image = new Uint8Array(Buffer.from(data, 'base64')) + return image.byteLength <= REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES ? image : null +} diff --git a/src/main/browser/browser-screenshot-admission.test.ts b/src/main/browser/browser-screenshot-admission.test.ts new file mode 100644 index 00000000000..f6b74b5d37e --- /dev/null +++ b/src/main/browser/browser-screenshot-admission.test.ts @@ -0,0 +1,95 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + resetBrowserScreenshotAdmissionForTests, + startBrowserFallbackCapture, + startBrowserScreenshotCommand +} from './browser-screenshot-admission' +import { BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES } from './browser-screenshot-limits' + +function deferred(): { + promise: Promise + reject: (error: Error) => void + resolve: (value: T) => void +} { + let reject!: (error: Error) => void + let resolve!: (value: T) => void + const promise = new Promise((accept, fail) => { + resolve = accept + reject = fail + }) + return { promise, reject, resolve } +} + +function createWebContents() { + const events = new EventEmitter() + return Object.assign(events, { + capturePage: vi.fn(), + debugger: { sendCommand: vi.fn() } + }) +} + +afterEach(() => { + resetBrowserScreenshotAdmissionForTests() +}) + +describe('browser screenshot admission', () => { + it('caps native capture work across different WebContents and releases on settlement', async () => { + const operations = Array.from({ length: BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES }, () => + deferred() + ) + const contents = operations.map((operation) => { + const webContents = createWebContents() + webContents.debugger.sendCommand.mockReturnValue(operation.promise) + return webContents + }) + const active = contents.map((webContents) => + startBrowserScreenshotCommand(webContents as never, 'Page.captureScreenshot', {}) + ) + + const overflow = createWebContents() + overflow.debugger.sendCommand.mockResolvedValue({ data: 'later' }) + expect( + startBrowserScreenshotCommand(overflow as never, 'Page.captureScreenshot', {}) + ).toBeNull() + + operations[0]!.resolve({ data: 'first' }) + await expect(active[0]).resolves.toEqual({ data: 'first' }) + await expect( + startBrowserScreenshotCommand(overflow as never, 'Page.captureScreenshot', {}) + ).resolves.toEqual({ data: 'later' }) + + operations[1]!.reject(new Error('capture failed')) + await expect(active[1]).rejects.toThrow('capture failed') + }) + + it('shares admission with fallback capture and releases synchronous failures', async () => { + const commandContents = createWebContents() + commandContents.debugger.sendCommand.mockImplementation(() => { + throw new Error('sync failure') + }) + await expect( + startBrowserScreenshotCommand(commandContents as never, 'Page.captureScreenshot', {}) + ).rejects.toThrow('sync failure') + + const fallbackContents = createWebContents() + const image = { isEmpty: () => false } + fallbackContents.capturePage.mockResolvedValue(image) + await expect(startBrowserFallbackCapture(fallbackContents as never)).resolves.toBe(image) + }) + + it('releases a hung operation when its WebContents is destroyed', async () => { + const hung = createWebContents() + hung.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) + expect( + startBrowserScreenshotCommand(hung as never, 'Page.captureScreenshot', {}) + ).not.toBeNull() + hung.emit('destroyed') + + const replacement = createWebContents() + replacement.debugger.sendCommand.mockResolvedValue({ data: 'replacement' }) + await expect( + startBrowserScreenshotCommand(replacement as never, 'Page.captureScreenshot', {}) + ).resolves.toEqual({ data: 'replacement' }) + }) +}) diff --git a/src/main/browser/browser-screenshot-admission.ts b/src/main/browser/browser-screenshot-admission.ts new file mode 100644 index 00000000000..4442da7d18c --- /dev/null +++ b/src/main/browser/browser-screenshot-admission.ts @@ -0,0 +1,64 @@ +import type { WebContents } from 'electron' +import { BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES } from './browser-screenshot-limits' + +let activeCaptures = 0 +let admissionEpoch = 0 + +function startBrowserScreenshotOperation( + webContents: WebContents, + start: () => Promise +): Promise | null { + if (activeCaptures >= BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES) { + return null + } + activeCaptures += 1 + const operationEpoch = admissionEpoch + + let released = false + const onDestroyed = (): void => release() + const release = (): void => { + if (released) { + return + } + released = true + webContents.removeListener?.('destroyed', onDestroyed) + if (operationEpoch === admissionEpoch) { + activeCaptures = Math.max(0, activeCaptures - 1) + } + } + // Why: destroying the native target releases its capture resources even if Chromium omits rejection. + webContents.once?.('destroyed', onDestroyed) + + let command: Promise + try { + command = start() + } catch (error) { + release() + return Promise.reject(error) + } + // Why: a timed-out native command cannot be cancelled, so it owns its slot until it truly settles. + void command.then(release, release) + return command +} + +export function startBrowserScreenshotCommand( + webContents: WebContents, + method: 'Page.captureScreenshot' | 'Page.getLayoutMetrics', + params: Record +): Promise | null { + return startBrowserScreenshotOperation( + webContents, + () => webContents.debugger.sendCommand(method, params) as Promise + ) +} + +export function startBrowserFallbackCapture( + webContents: WebContents +): Promise | null { + return startBrowserScreenshotOperation(webContents, () => webContents.capturePage()) +} + +export function resetBrowserScreenshotAdmissionForTests(): void { + admissionEpoch += 1 + activeCaptures = 0 +} diff --git a/src/main/browser/browser-screenshot-file-reader.test.ts b/src/main/browser/browser-screenshot-file-reader.test.ts new file mode 100644 index 00000000000..a608d2d3dfa --- /dev/null +++ b/src/main/browser/browser-screenshot-file-reader.test.ts @@ -0,0 +1,36 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readBrowserScreenshotFile } from './browser-screenshot-file-reader' +import { BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR } from './browser-screenshot-limits' + +const temporaryDirectories: string[] = [] + +function createFile(bytes: Buffer): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-screenshot-reader-')) + temporaryDirectories.push(directory) + const path = join(directory, 'screenshot.png') + writeFileSync(path, bytes) + return path +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('readBrowserScreenshotFile', () => { + it('reads a screenshot at the byte boundary', () => { + const path = createFile(Buffer.from('1234')) + + expect(readBrowserScreenshotFile(path, 4)).toEqual(Buffer.from('1234')) + }) + + it('rejects an oversized screenshot before allocating its contents', () => { + const path = createFile(Buffer.from('12345')) + + expect(() => readBrowserScreenshotFile(path, 4)).toThrow(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + }) +}) diff --git a/src/main/browser/browser-screenshot-file-reader.ts b/src/main/browser/browser-screenshot-file-reader.ts new file mode 100644 index 00000000000..85a24734ed0 --- /dev/null +++ b/src/main/browser/browser-screenshot-file-reader.ts @@ -0,0 +1,54 @@ +import { closeSync, fstatSync, openSync, readSync } from 'node:fs' +import { + BROWSER_SCREENSHOT_MAX_ENCODED_BYTES, + BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR +} from './browser-screenshot-limits' + +const MIN_GROWTH_BYTES = 64 * 1024 + +function throwIfOverLimit(bytes: number, maxBytes: number): void { + if (bytes > maxBytes) { + throw new Error(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + } +} + +export function readBrowserScreenshotFile( + path: string, + maxBytes = BROWSER_SCREENSHOT_MAX_ENCODED_BYTES +): Buffer { + const descriptor = openSync(path, 'r') + try { + const initialSize = fstatSync(descriptor).size + throwIfOverLimit(initialSize, maxBytes) + let bytes = Buffer.allocUnsafe(initialSize) + let offset = 0 + + while (true) { + while (offset < bytes.length) { + const read = readSync(descriptor, bytes, offset, bytes.length - offset, null) + if (read === 0) { + return bytes.subarray(0, offset) + } + offset += read + } + + const probe = Buffer.allocUnsafe(1) + if (readSync(descriptor, probe, 0, 1, null) === 0) { + return bytes.subarray(0, offset) + } + throwIfOverLimit(offset + 1, maxBytes) + + const nextCapacity = Math.min( + maxBytes, + Math.max(MIN_GROWTH_BYTES, bytes.length * 2, offset + 1) + ) + const expanded = Buffer.allocUnsafe(nextCapacity) + bytes.copy(expanded, 0, 0, offset) + expanded[offset] = probe[0]! + bytes = expanded + offset += 1 + } + } finally { + closeSync(descriptor) + } +} diff --git a/src/main/browser/browser-screenshot-limits.test.ts b/src/main/browser/browser-screenshot-limits.test.ts new file mode 100644 index 00000000000..1379905e127 --- /dev/null +++ b/src/main/browser/browser-screenshot-limits.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + assertBrowserScreenshotEncodedBytes, + assertBrowserScreenshotGeometry, + BROWSER_SCREENSHOT_MAX_EFFECTIVE_PIXELS, + BROWSER_SCREENSHOT_MAX_ENCODED_BYTES, + BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR +} from './browser-screenshot-limits' + +describe('browser screenshot memory limits', () => { + it('accepts ordinary HiDPI viewport geometry', () => { + expect(() => assertBrowserScreenshotGeometry(1_440, 900, 2)).not.toThrow() + }) + + it('counts device scale in the effective pixel budget', () => { + const side = Math.floor(Math.sqrt(BROWSER_SCREENSHOT_MAX_EFFECTIVE_PIXELS)) + + expect(() => assertBrowserScreenshotGeometry(side, side, 2)).toThrow( + BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR + ) + }) + + it('accepts the encoded-byte boundary and rejects the next byte', () => { + expect(() => + assertBrowserScreenshotEncodedBytes(BROWSER_SCREENSHOT_MAX_ENCODED_BYTES) + ).not.toThrow() + expect(() => + assertBrowserScreenshotEncodedBytes(BROWSER_SCREENSHOT_MAX_ENCODED_BYTES + 1) + ).toThrow(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + }) +}) diff --git a/src/main/browser/browser-screenshot-limits.ts b/src/main/browser/browser-screenshot-limits.ts new file mode 100644 index 00000000000..c440369badd --- /dev/null +++ b/src/main/browser/browser-screenshot-limits.ts @@ -0,0 +1,37 @@ +export const BROWSER_SCREENSHOT_MAX_DIMENSION_PX = 32_768 +export const BROWSER_SCREENSHOT_MAX_EFFECTIVE_PIXELS = 32 * 1024 * 1024 +export const BROWSER_SCREENSHOT_MAX_ENCODED_BYTES = 32 * 1024 * 1024 +export const BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES = 2 +export const BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR = + 'Screenshot exceeds the browser automation memory limit' +export const BROWSER_SCREENSHOT_BUSY_ERROR = 'Too many screenshot requests are already running' + +export function assertBrowserScreenshotGeometry(width: number, height: number, scale = 1): void { + const effectiveWidth = width * scale + const effectiveHeight = height * scale + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + !Number.isFinite(scale) || + width <= 0 || + height <= 0 || + scale <= 0 || + effectiveWidth > BROWSER_SCREENSHOT_MAX_DIMENSION_PX || + effectiveHeight > BROWSER_SCREENSHOT_MAX_DIMENSION_PX || + effectiveWidth * effectiveHeight > BROWSER_SCREENSHOT_MAX_EFFECTIVE_PIXELS + ) { + throw new Error(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + } +} + +export function assertBrowserScreenshotEncodedBytes(bytes: number): void { + if (!Number.isFinite(bytes) || bytes < 0 || bytes > BROWSER_SCREENSHOT_MAX_ENCODED_BYTES) { + throw new Error(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + } +} + +export function assertBrowserScreenshotBase64(data: string): void { + const padding = data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0 + const decodedBytes = Math.max(0, Math.floor((data.length * 3) / 4) - padding) + assertBrowserScreenshotEncodedBytes(decodedBytes) +} diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index 47d263ac371..2d6ac9f8dd4 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -48,8 +48,16 @@ import { import { insertTextThroughCdp } from './browser-text-insertion' import type { BrowserManager } from './browser-manager' import { ANTI_DETECTION_SCRIPT } from './anti-detection' - -const CAPTURE_LOG_LIMIT = 1000 +import { CdpCommandQueue } from './cdp-command-queue' +import { + buildBoundedCdpConsoleEntry, + buildBoundedCdpInterceptedRequest, + buildBoundedCdpNetworkEntry, + CDP_CAPTURE_LOG_LIMIT, + CDP_MAX_IFRAME_SESSIONS, + CDP_MAX_PAUSED_REQUESTS, + isBoundedCdpIframeSession +} from './cdp-event-memory-bounds' export class BrowserError extends Error { constructor( @@ -79,21 +87,21 @@ type TabState = { networkRequestMap: Map } -type QueuedCommand = { - execute: () => Promise - resolve: (value: unknown) => void - reject: (reason: unknown) => void -} - export class CdpBridge { private activeWebContentsId: number | null = null private readonly tabState = new Map() - private readonly commandQueues = new Map() - private readonly processingQueues = new Set() + private readonly commandQueue: CdpCommandQueue private readonly browserManager: BrowserManager constructor(browserManager: BrowserManager) { this.browserManager = browserManager + this.commandQueue = new CdpCommandQueue( + () => + new BrowserError( + 'browser_busy', + 'Browser command queue is full; retry after the current commands finish' + ) + ) } setActiveTab(webContentsId: number): void { @@ -1010,7 +1018,13 @@ export class CdpBridge { this.removeDebuggerListeners(guest, state) } this.tabState.delete(tabId) - this.commandQueues.delete(tabId) + this.commandQueue.closeTab( + tabId, + new BrowserError( + 'browser_debugger_detached', + 'Browser tab closed before command execution.' + ) + ) } } @@ -1195,10 +1209,21 @@ export class CdpBridge { } | undefined if (p?.sessionId && p.targetInfo?.type === 'iframe' && p.targetInfo.targetId) { - state.iframeSessions.set(p.targetInfo.targetId, p.sessionId) - guest.debugger.sendCommand('DOM.enable', {}, p.sessionId).catch(() => {}) - guest.debugger.sendCommand('Accessibility.enable', {}, p.sessionId).catch(() => {}) - guest.debugger.sendCommand('Runtime.enable', {}, p.sessionId).catch(() => {}) + const frameId = p.targetInfo.targetId + const canRetainSession = + isBoundedCdpIframeSession(frameId, p.sessionId) && + (state.iframeSessions.has(frameId) || + state.iframeSessions.size < CDP_MAX_IFRAME_SESSIONS) + if (canRetainSession) { + state.iframeSessions.set(frameId, p.sessionId) + guest.debugger.sendCommand('DOM.enable', {}, p.sessionId).catch(() => {}) + guest.debugger.sendCommand('Accessibility.enable', {}, p.sessionId).catch(() => {}) + guest.debugger.sendCommand('Runtime.enable', {}, p.sessionId).catch(() => {}) + } else { + guest.debugger + .sendCommand('Target.detachFromTarget', { sessionId: p.sessionId }) + .catch(() => {}) + } } } if (method === 'Target.detachedFromTarget') { @@ -1224,15 +1249,8 @@ export class CdpBridge { } | undefined if (p) { - const text = (p.args ?? []).map((a) => a.value ?? a.description ?? '').join(' ') - state.consoleLog.push({ - level: p.type ?? 'log', - text, - timestamp: p.timestamp ?? Date.now(), - url: p.stackTrace?.callFrames?.[0]?.url, - line: p.stackTrace?.callFrames?.[0]?.lineNumber - }) - if (state.consoleLog.length > CAPTURE_LOG_LIMIT) { + state.consoleLog.push(buildBoundedCdpConsoleEntry(p)) + if (state.consoleLog.length > CDP_CAPTURE_LOG_LIMIT) { state.consoleLog.shift() } } @@ -1252,20 +1270,13 @@ export class CdpBridge { } | undefined if (p?.response) { - const entry: BrowserNetworkEntry = { - url: p.response.url ?? '', - method: '', - status: p.response.status ?? 0, - mimeType: p.response.mimeType ?? '', - size: 0, - timestamp: p.timestamp ?? Date.now() - } + const entry = buildBoundedCdpNetworkEntry(p.response, p.timestamp) state.networkLog.push(entry) // Why: map requestId→entry so loadingFinished attributes size to the right response, not the latest one. if (p.requestId) { state.networkRequestMap.set(p.requestId, entry) } - if (state.networkLog.length > CAPTURE_LOG_LIMIT) { + if (state.networkLog.length > CDP_CAPTURE_LOG_LIMIT) { const evicted = state.networkLog.shift() if (evicted) { for (const [requestId, requestEntry] of state.networkRequestMap) { @@ -1299,13 +1310,23 @@ export class CdpBridge { } | undefined if (p?.requestId && p.request) { - state.pausedRequests.set(p.requestId, { - id: p.requestId, - url: p.request.url ?? '', - method: p.request.method ?? 'GET', - headers: (p.request.headers ?? {}) as Record, - resourceType: p.resourceType ?? 'Other' + const boundedRequest = buildBoundedCdpInterceptedRequest({ + requestId: p.requestId, + request: p.request, + resourceType: p.resourceType }) + if ( + boundedRequest && + (state.pausedRequests.has(p.requestId) || + state.pausedRequests.size < CDP_MAX_PAUSED_REQUESTS) + ) { + state.pausedRequests.set(p.requestId, boundedRequest) + } else { + state.pausedRequests.delete(p.requestId) + guest.debugger + .sendCommand('Fetch.continueRequest', { requestId: p.requestId }) + .catch(() => {}) + } } } } @@ -1681,40 +1702,7 @@ export class CdpBridge { private async enqueueCommand(execute: () => Promise): Promise { const guest = this.getActiveGuest() const tabId = this.resolveTabId(guest.id) - - return new Promise((resolve, reject) => { - let queue = this.commandQueues.get(tabId) - if (!queue) { - queue = [] - this.commandQueues.set(tabId, queue) - } - queue.push({ - execute: execute as () => Promise, - resolve: resolve as (value: unknown) => void, - reject - }) - this.processQueue(tabId) - }) - } - - private async processQueue(tabId: string): Promise { - if (this.processingQueues.has(tabId)) { - return - } - this.processingQueues.add(tabId) - - const queue = this.commandQueues.get(tabId) - while (queue && queue.length > 0) { - const cmd = queue.shift()! - try { - const result = await cmd.execute() - cmd.resolve(result) - } catch (error) { - cmd.reject(error) - } - } - - this.processingQueues.delete(tabId) + return this.commandQueue.enqueue(tabId, execute) } } diff --git a/src/main/browser/cdp-command-queue.test.ts b/src/main/browser/cdp-command-queue.test.ts new file mode 100644 index 00000000000..04f919b900a --- /dev/null +++ b/src/main/browser/cdp-command-queue.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { CdpCommandQueue } from './cdp-command-queue' + +function deferred(): { + promise: Promise + resolve: (value: T | PromiseLike) => void +} { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe('CdpCommandQueue', () => { + it('preserves ordinary per-tab command order', async () => { + const first = deferred() + const calls: number[] = [] + const queue = new CdpCommandQueue(() => new Error('full'), 4, 8) + const one = queue.enqueue('tab-1', async () => { + calls.push(1) + await first.promise + return 'one' + }) + const two = queue.enqueue('tab-1', async () => { + calls.push(2) + return 'two' + }) + + expect(calls).toEqual([1]) + first.resolve() + + await expect(Promise.all([one, two])).resolves.toEqual(['one', 'two']) + expect(calls).toEqual([1, 2]) + }) + + it('rejects commands beyond per-tab and aggregate queue caps', async () => { + const first = deferred() + const second = deferred() + const queue = new CdpCommandQueue(() => new Error('full'), 2, 3) + const active = queue.enqueue('tab-1', () => first.promise) + const activeTwo = queue.enqueue('tab-2', () => second.promise) + const queued = [ + queue.enqueue('tab-1', async () => 1), + queue.enqueue('tab-1', async () => 2), + queue.enqueue('tab-2', async () => 3) + ] + + await expect(queue.enqueue('tab-1', async () => 4)).rejects.toThrow('full') + await expect(queue.enqueue('tab-3', async () => 5)).rejects.toThrow('full') + + first.resolve() + second.resolve() + await expect(Promise.all([active, activeTwo, ...queued])).resolves.toEqual([ + undefined, + undefined, + 1, + 2, + 3 + ]) + }) + + it('rejects retained commands when a tab closes and continues a replacement queue', async () => { + const first = deferred() + const executeAfterClose = vi.fn(async () => 'stale') + const queue = new CdpCommandQueue(() => new Error('full'), 4, 8) + const active = queue.enqueue('tab-1', () => first.promise) + const stale = queue.enqueue('tab-1', executeAfterClose) + + queue.closeTab('tab-1', new Error('closed')) + await expect(stale).rejects.toThrow('closed') + expect(executeAfterClose).not.toHaveBeenCalled() + + const replacement = queue.enqueue('tab-1', async () => 'fresh') + first.resolve() + await expect(active).resolves.toBeUndefined() + await expect(replacement).resolves.toBe('fresh') + }) +}) diff --git a/src/main/browser/cdp-command-queue.ts b/src/main/browser/cdp-command-queue.ts new file mode 100644 index 00000000000..71ea74e68fc --- /dev/null +++ b/src/main/browser/cdp-command-queue.ts @@ -0,0 +1,80 @@ +export const CDP_MAX_QUEUED_COMMANDS_PER_TAB = 64 +export const CDP_MAX_QUEUED_COMMANDS_TOTAL = 512 + +type QueuedCdpCommand = { + execute: () => Promise + resolve: (value: unknown) => void + reject: (reason: unknown) => void +} + +export class CdpCommandQueue { + private readonly queues = new Map() + private readonly processing = new Set() + private queuedCount = 0 + + constructor( + private readonly createOverflowError: () => Error, + private readonly maxQueuedPerTab = CDP_MAX_QUEUED_COMMANDS_PER_TAB, + private readonly maxQueuedTotal = CDP_MAX_QUEUED_COMMANDS_TOTAL + ) {} + + enqueue(tabId: string, execute: () => Promise): Promise { + return new Promise((resolve, reject) => { + let queue = this.queues.get(tabId) + if ((queue?.length ?? 0) >= this.maxQueuedPerTab || this.queuedCount >= this.maxQueuedTotal) { + reject(this.createOverflowError()) + return + } + if (!queue) { + queue = [] + this.queues.set(tabId, queue) + } + queue.push({ + execute: execute as () => Promise, + resolve: resolve as (value: unknown) => void, + reject + }) + this.queuedCount += 1 + void this.process(tabId) + }) + } + + closeTab(tabId: string, error: Error): void { + const queue = this.queues.get(tabId) + if (!queue) { + return + } + this.queues.delete(tabId) + for (const command of queue.splice(0)) { + this.queuedCount = Math.max(0, this.queuedCount - 1) + command.reject(error) + } + } + + private async process(tabId: string): Promise { + if (this.processing.has(tabId)) { + return + } + this.processing.add(tabId) + const queue = this.queues.get(tabId) + try { + while (queue && queue.length > 0) { + const command = queue.shift()! + this.queuedCount = Math.max(0, this.queuedCount - 1) + try { + command.resolve(await command.execute()) + } catch (error) { + command.reject(error) + } + } + if (this.queues.get(tabId) === queue) { + this.queues.delete(tabId) + } + } finally { + this.processing.delete(tabId) + if ((this.queues.get(tabId)?.length ?? 0) > 0) { + void this.process(tabId) + } + } + } +} diff --git a/src/main/browser/cdp-event-memory-bounds.test.ts b/src/main/browser/cdp-event-memory-bounds.test.ts new file mode 100644 index 00000000000..7d30f6c0bfb --- /dev/null +++ b/src/main/browser/cdp-event-memory-bounds.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + buildBoundedCdpConsoleEntry, + buildBoundedCdpInterceptedRequest, + buildBoundedCdpNetworkEntry, + CDP_MAX_CAPTURE_URL_CODE_UNITS, + CDP_MAX_CONSOLE_ARGUMENTS, + CDP_MAX_CONSOLE_TEXT_CODE_UNITS, + CDP_MAX_INTERCEPTED_HEADERS, + CDP_MAX_INTERCEPTED_METADATA_CODE_UNITS +} from './cdp-event-memory-bounds' + +describe('CDP event memory bounds', () => { + it('preserves ordinary console and network capture fields', () => { + expect( + buildBoundedCdpConsoleEntry({ + type: 'warning', + args: [{ value: 'hello' }, { value: 42 }], + timestamp: 123, + stackTrace: { callFrames: [{ url: 'https://example.com/app.js', lineNumber: 7 }] } + }) + ).toEqual({ + level: 'warning', + text: 'hello 42', + timestamp: 123, + url: 'https://example.com/app.js', + line: 7 + }) + expect( + buildBoundedCdpNetworkEntry( + { url: 'https://example.com/data', status: 200, mimeType: 'application/json' }, + 456 + ) + ).toEqual({ + url: 'https://example.com/data', + method: '', + status: 200, + mimeType: 'application/json', + size: 0, + timestamp: 456 + }) + }) + + it('bounds console argument count, joined text, and capture URLs', () => { + const entry = buildBoundedCdpConsoleEntry({ + args: Array.from({ length: CDP_MAX_CONSOLE_ARGUMENTS + 100 }, () => ({ + value: 'x'.repeat(100) + })), + stackTrace: { callFrames: [{ url: 'u'.repeat(CDP_MAX_CAPTURE_URL_CODE_UNITS + 100) }] } + }) + + expect(entry.text.length).toBeLessThanOrEqual(CDP_MAX_CONSOLE_TEXT_CODE_UNITS) + expect(entry.url).toHaveLength(CDP_MAX_CAPTURE_URL_CODE_UNITS) + }) + + it('rejects intercepted metadata beyond aggregate and header-count caps', () => { + expect( + buildBoundedCdpInterceptedRequest({ + requestId: 'request-1', + request: { url: 'https://example.com', headers: { accept: '*/*' } } + }) + ).toEqual({ + id: 'request-1', + url: 'https://example.com', + method: 'GET', + headers: { accept: '*/*' }, + resourceType: 'Other' + }) + + expect( + buildBoundedCdpInterceptedRequest({ + requestId: 'request-2', + request: { url: 'u'.repeat(CDP_MAX_INTERCEPTED_METADATA_CODE_UNITS + 1) } + }) + ).toBeNull() + + const headers = Object.fromEntries( + Array.from({ length: CDP_MAX_INTERCEPTED_HEADERS + 1 }, (_, index) => [`x-${index}`, 'v']) + ) + expect( + buildBoundedCdpInterceptedRequest({ requestId: 'request-3', request: { headers } }) + ).toBeNull() + }) +}) diff --git a/src/main/browser/cdp-event-memory-bounds.ts b/src/main/browser/cdp-event-memory-bounds.ts new file mode 100644 index 00000000000..c2fdfa61649 --- /dev/null +++ b/src/main/browser/cdp-event-memory-bounds.ts @@ -0,0 +1,145 @@ +import type { + BrowserConsoleEntry, + BrowserInterceptedRequest, + BrowserNetworkEntry +} from '../../shared/runtime-types' + +export const CDP_CAPTURE_LOG_LIMIT = 1000 +export const CDP_MAX_PAUSED_REQUESTS = 256 +export const CDP_MAX_IFRAME_SESSIONS = 1024 +export const CDP_MAX_CONSOLE_ARGUMENTS = 1024 +export const CDP_MAX_CONSOLE_TEXT_CODE_UNITS = 16 * 1024 +export const CDP_MAX_CAPTURE_URL_CODE_UNITS = 16 * 1024 +export const CDP_MAX_INTERCEPTED_HEADERS = 256 +export const CDP_MAX_INTERCEPTED_METADATA_CODE_UNITS = 64 * 1024 +const CDP_MAX_LEVEL_CODE_UNITS = 32 +const CDP_MAX_MIME_TYPE_CODE_UNITS = 1024 +const CDP_MAX_SESSION_ID_CODE_UNITS = 1024 + +type ConsoleEvent = { + type?: string + args?: { value?: unknown; description?: unknown }[] + timestamp?: number + stackTrace?: { callFrames?: { url?: string; lineNumber?: number }[] } +} + +type InterceptedRequestEvent = { + requestId: string + request: { url?: string; method?: string; headers?: Record } + resourceType?: string +} + +export function buildBoundedCdpConsoleEntry(event: ConsoleEvent): BrowserConsoleEntry { + const parts: string[] = [] + let remaining = CDP_MAX_CONSOLE_TEXT_CODE_UNITS + const args = event.args ?? [] + const argumentCount = Math.min(args.length, CDP_MAX_CONSOLE_ARGUMENTS) + for (let index = 0; index < argumentCount && remaining > 0; index++) { + if (index > 0) { + parts.push(' ') + remaining -= 1 + if (remaining <= 0) { + break + } + } + const raw = consoleArgumentText(args[index]) + const value = truncateCdpField(raw, remaining) + parts.push(value) + remaining -= value.length + } + return { + level: truncateCdpField(event.type ?? 'log', CDP_MAX_LEVEL_CODE_UNITS), + text: parts.join(''), + timestamp: event.timestamp ?? Date.now(), + url: truncateOptionalCdpField( + event.stackTrace?.callFrames?.[0]?.url, + CDP_MAX_CAPTURE_URL_CODE_UNITS + ), + line: event.stackTrace?.callFrames?.[0]?.lineNumber + } +} + +export function buildBoundedCdpNetworkEntry( + response: { url?: string; status?: number; mimeType?: string }, + timestamp?: number +): BrowserNetworkEntry { + return { + url: truncateCdpField(response.url ?? '', CDP_MAX_CAPTURE_URL_CODE_UNITS), + method: '', + status: response.status ?? 0, + mimeType: truncateCdpField(response.mimeType ?? '', CDP_MAX_MIME_TYPE_CODE_UNITS), + size: 0, + timestamp: timestamp ?? Date.now() + } +} + +export function buildBoundedCdpInterceptedRequest( + event: InterceptedRequestEvent +): BrowserInterceptedRequest | null { + let remaining = CDP_MAX_INTERCEPTED_METADATA_CODE_UNITS + const reserve = (value: string): boolean => { + if (value.length > remaining) { + return false + } + remaining -= value.length + return true + } + const url = event.request.url ?? '' + const method = event.request.method ?? 'GET' + const resourceType = event.resourceType ?? 'Other' + if (![event.requestId, url, method, resourceType].every(reserve)) { + return null + } + + const headers: Record = Object.create(null) as Record + let headerCount = 0 + for (const name in event.request.headers ?? {}) { + if (!Object.hasOwn(event.request.headers ?? {}, name)) { + continue + } + const value = event.request.headers![name] + if ( + headerCount >= CDP_MAX_INTERCEPTED_HEADERS || + typeof value !== 'string' || + !reserve(name) || + !reserve(value) + ) { + return null + } + headers[name] = value + headerCount += 1 + } + return { id: event.requestId, url, method, headers, resourceType } +} + +export function isBoundedCdpIframeSession(frameId: string, sessionId: string): boolean { + return ( + frameId.length <= CDP_MAX_SESSION_ID_CODE_UNITS && + sessionId.length <= CDP_MAX_SESSION_ID_CODE_UNITS + ) +} + +function consoleArgumentText(arg: { value?: unknown; description?: unknown } | undefined): string { + const value = arg?.value ?? arg?.description ?? '' + if (typeof value === 'string') { + return value + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value) + } + return '' +} + +function truncateOptionalCdpField( + value: string | undefined, + maxLength: number +): string | undefined { + return value === undefined ? undefined : truncateCdpField(value, maxLength) +} + +function truncateCdpField(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value + } + return `${value.slice(0, Math.max(0, maxLength - 1))}…` +} diff --git a/src/main/browser/cdp-print-to-pdf.test.ts b/src/main/browser/cdp-print-to-pdf.test.ts index e6f2163b5dd..8391599fd9a 100644 --- a/src/main/browser/cdp-print-to-pdf.test.ts +++ b/src/main/browser/cdp-print-to-pdf.test.ts @@ -1,8 +1,22 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' +import { + buildPrintToPdfOptions, + CDP_PDF_MAX_RETAINED_STREAMS, + CDP_PDF_MEMORY_LIMIT_ERROR, + CdpPdfRetentionBudget, + CdpPdfStreamStore +} from './cdp-print-to-pdf' +import type { CdpPdfStreamStoreOptions } from './cdp-print-to-pdf' const DEFAULT_MARGIN_INCHES = 1 / 2.54 const TTL_MS = 5 * 60 * 1000 +const stores = new Set() + +function createStore(options: CdpPdfStreamStoreOptions = {}): CdpPdfStreamStore { + const store = new CdpPdfStreamStore(options) + stores.add(store) + return store +} describe('buildPrintToPdfOptions', () => { it('returns empty options for empty params', () => { @@ -78,11 +92,15 @@ describe('buildPrintToPdfOptions', () => { describe('CdpPdfStreamStore', () => { afterEach(() => { + for (const store of stores) { + store.clear() + } + stores.clear() vi.useRealTimers() }) it('claims only its own handles', () => { - const store = new CdpPdfStreamStore() + const store = createStore() const handle = store.create(Buffer.from('pdf')) expect(store.ownsHandle({ handle })).toBe(true) @@ -93,12 +111,12 @@ describe('CdpPdfStreamStore', () => { }) it('mints distinct handles per stream', () => { - const store = new CdpPdfStreamStore() + const store = createStore() expect(store.create(Buffer.from('a'))).not.toBe(store.create(Buffer.from('b'))) }) it('reads sequential chunks and reports eof', () => { - const store = new CdpPdfStreamStore() + const store = createStore() const handle = store.create(Buffer.from('abcdef')) expect(store.read({ handle, size: 2 })).toEqual({ @@ -112,7 +130,7 @@ describe('CdpPdfStreamStore', () => { }) it('honors an explicit read offset', () => { - const store = new CdpPdfStreamStore() + const store = createStore() const handle = store.create(Buffer.from('abcdef')) expect(store.read({ handle, offset: 4 })).toEqual({ @@ -122,20 +140,20 @@ describe('CdpPdfStreamStore', () => { }) it('returns an empty eof chunk when reading past the end', () => { - const store = new CdpPdfStreamStore() + const store = createStore() const handle = store.create(Buffer.from('abc')) expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true }) }) it('returns null for unknown handles', () => { - const store = new CdpPdfStreamStore() + const store = createStore() expect(store.read({ handle: 'nope' })).toBeNull() expect(store.read({})).toBeNull() }) it('drops a stream on close', () => { - const store = new CdpPdfStreamStore() + const store = createStore() const handle = store.create(Buffer.from('abc')) store.close({ handle }) @@ -143,7 +161,7 @@ describe('CdpPdfStreamStore', () => { }) it('drops all streams on clear', () => { - const store = new CdpPdfStreamStore() + const store = createStore() const handle = store.create(Buffer.from('abc')) store.clear() @@ -152,7 +170,7 @@ describe('CdpPdfStreamStore', () => { it('evicts an abandoned stream after the TTL', () => { vi.useFakeTimers() - const store = new CdpPdfStreamStore() + const store = createStore() const handle = store.create(Buffer.from('abc')) vi.advanceTimersByTime(TTL_MS + 1) @@ -161,7 +179,7 @@ describe('CdpPdfStreamStore', () => { it('refreshes the TTL on each read', () => { vi.useFakeTimers() - const store = new CdpPdfStreamStore() + const store = createStore() const handle = store.create(Buffer.from('abcdef')) vi.advanceTimersByTime(TTL_MS - 1) @@ -169,4 +187,67 @@ describe('CdpPdfStreamStore', () => { vi.advanceTimersByTime(TTL_MS - 1) expect(store.read({ handle, size: 1 })).not.toBeNull() }) + + it('rejects aggregate stream retention past its count or byte budget', () => { + const store = createStore({ maxStreams: 2, maxRetainedBytes: 5 }) + const first = store.create(Buffer.from('ab')) + store.create(Buffer.from('cde')) + + expect(() => store.create(Buffer.alloc(0))).toThrow(CDP_PDF_MEMORY_LIMIT_ERROR) + store.close({ handle: first }) + expect(() => store.create(Buffer.from('fg'))).not.toThrow() + expect(() => createStore({ maxRetainedBytes: 5 }).create(Buffer.alloc(6))).toThrow( + CDP_PDF_MEMORY_LIMIT_ERROR + ) + store.clear() + }) + + it('clamps oversized IO.read requests to a bounded base64 chunk', () => { + const store = createStore({ maxReadChunkBytes: 2 }) + const handle = store.create(Buffer.from('abcdef')) + + expect(store.read({ handle, size: Number.MAX_SAFE_INTEGER })).toEqual({ + data: Buffer.from('ab').toString('base64'), + eof: false + }) + store.clear() + }) + + it('shares the default retained-stream cap across independent proxy stores', () => { + const admitted = Array.from({ length: CDP_PDF_MAX_RETAINED_STREAMS }, () => { + const store = createStore() + return { handle: store.create(Buffer.alloc(0)), store } + }) + const overflow = createStore() + + expect(() => overflow.create(Buffer.alloc(0))).toThrow(CDP_PDF_MEMORY_LIMIT_ERROR) + + admitted[0]!.store.close({ handle: admitted[0]!.handle }) + expect(() => overflow.create(Buffer.alloc(0))).not.toThrow() + }) + + it('releases a shared byte budget on close, expiry, clear, and failed admission', () => { + vi.useFakeTimers() + const budget = new CdpPdfRetentionBudget(4, 5) + const first = createStore({ maxRetainedBytes: 5, retentionBudget: budget }) + const second = createStore({ maxRetainedBytes: 5, retentionBudget: budget }) + const firstHandle = first.create(Buffer.from('abc')) + const secondHandle = second.create(Buffer.from('de')) + + expect(budget.inspect()).toEqual({ retainedBytes: 5, retainedStreams: 2 }) + expect(() => second.create(Buffer.from('f'))).toThrow(CDP_PDF_MEMORY_LIMIT_ERROR) + expect(budget.inspect()).toEqual({ retainedBytes: 5, retainedStreams: 2 }) + + first.close({ handle: firstHandle }) + expect(budget.inspect()).toEqual({ retainedBytes: 2, retainedStreams: 1 }) + second.close({ handle: secondHandle }) + const expiring = first.create(Buffer.from('xy')) + expect(first.read({ handle: expiring })).not.toBeNull() + vi.advanceTimersByTime(TTL_MS + 1) + expect(budget.inspect()).toEqual({ retainedBytes: 0, retainedStreams: 0 }) + + first.create(Buffer.from('z')) + first.clear() + expect(budget.inspect()).toEqual({ retainedBytes: 0, retainedStreams: 0 }) + }) }) diff --git a/src/main/browser/cdp-print-to-pdf.ts b/src/main/browser/cdp-print-to-pdf.ts index 643013377d8..bc0999012cb 100644 --- a/src/main/browser/cdp-print-to-pdf.ts +++ b/src/main/browser/cdp-print-to-pdf.ts @@ -5,6 +5,9 @@ const PDF_DEFAULT_MARGIN_INCHES = 1 / 2.54 const PDF_STREAM_CHUNK_BYTES = 1024 * 1024 const PDF_STREAM_HANDLE_PREFIX = 'orca-pdf-' const PDF_STREAM_TTL_MS = 5 * 60 * 1000 +export const CDP_PDF_MAX_RETAINED_STREAMS = 8 +export const CDP_PDF_MAX_RETAINED_BYTES = 64 * 1024 * 1024 +export const CDP_PDF_MEMORY_LIMIT_ERROR = 'PDF exceeds the browser automation memory limit' function finiteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null @@ -80,6 +83,7 @@ type PdfStream = { data: Buffer offset: number cleanupTimer: ReturnType + releaseRetention: () => void } export type PdfStreamChunk = { @@ -87,6 +91,57 @@ export type PdfStreamChunk = { eof: boolean } +export type CdpPdfStreamStoreOptions = { + maxStreams?: number + maxRetainedBytes?: number + maxReadChunkBytes?: number + retentionBudget?: CdpPdfRetentionBudget +} + +export function assertCdpPdfWithinMemoryLimit(data: Buffer): void { + if (data.length > CDP_PDF_MAX_RETAINED_BYTES) { + throw new Error(CDP_PDF_MEMORY_LIMIT_ERROR) + } +} + +export class CdpPdfRetentionBudget { + private retainedBytes = 0 + private retainedStreams = 0 + + constructor( + private readonly maxStreams = CDP_PDF_MAX_RETAINED_STREAMS, + private readonly maxRetainedBytes = CDP_PDF_MAX_RETAINED_BYTES + ) {} + + retain(bytes: number): (() => void) | null { + if ( + !Number.isSafeInteger(bytes) || + bytes < 0 || + this.retainedStreams >= this.maxStreams || + this.retainedBytes + bytes > this.maxRetainedBytes + ) { + return null + } + this.retainedStreams += 1 + this.retainedBytes += bytes + let released = false + return () => { + if (released) { + return + } + released = true + this.retainedStreams = Math.max(0, this.retainedStreams - 1) + this.retainedBytes = Math.max(0, this.retainedBytes - bytes) + } + } + + inspect(): { retainedBytes: number; retainedStreams: number } { + return { retainedBytes: this.retainedBytes, retainedStreams: this.retainedStreams } + } +} + +const processPdfRetentionBudget = new CdpPdfRetentionBudget() + /** * Holds the PDF buffers produced for CDP `transferMode: "ReturnAsStream"` and * serves them back through `IO.read` / `IO.close`, the path Playwright's @@ -97,7 +152,25 @@ export type PdfStreamChunk = { export class CdpPdfStreamStore { private readonly streams = new Map() private readonly handlePrefix = `${PDF_STREAM_HANDLE_PREFIX}${randomUUID()}-` + private readonly maxStreams: number + private readonly maxRetainedBytes: number + private readonly maxReadChunkBytes: number + private readonly retentionBudget: CdpPdfRetentionBudget private nextId = 0 + private retainedBytes = 0 + + constructor(options: CdpPdfStreamStoreOptions = {}) { + this.maxStreams = Math.max(0, Math.floor(options.maxStreams ?? CDP_PDF_MAX_RETAINED_STREAMS)) + this.maxRetainedBytes = Math.max( + 0, + Math.floor(options.maxRetainedBytes ?? CDP_PDF_MAX_RETAINED_BYTES) + ) + this.maxReadChunkBytes = Math.max( + 1, + Math.floor(options.maxReadChunkBytes ?? PDF_STREAM_CHUNK_BYTES) + ) + this.retentionBudget = options.retentionBudget ?? processPdfRetentionBudget + } /** True when `params.handle` names one of this store's streams. */ ownsHandle(params: Record): boolean { @@ -105,12 +178,30 @@ export class CdpPdfStreamStore { } create(data: Buffer): string { + if ( + data.length > this.maxRetainedBytes || + this.streams.size >= this.maxStreams || + this.retainedBytes + data.length > this.maxRetainedBytes + ) { + throw new Error(CDP_PDF_MEMORY_LIMIT_ERROR) + } + const releaseRetention = this.retentionBudget.retain(data.length) + if (!releaseRetention) { + throw new Error(CDP_PDF_MEMORY_LIMIT_ERROR) + } const handle = `${this.handlePrefix}${++this.nextId}` - this.streams.set(handle, { - data, - offset: 0, - cleanupTimer: this.scheduleCleanup(handle) - }) + let cleanupTimer: ReturnType | null = null + try { + cleanupTimer = this.scheduleCleanup(handle) + this.streams.set(handle, { data, offset: 0, cleanupTimer, releaseRetention }) + } catch (error) { + if (cleanupTimer) { + clearTimeout(cleanupTimer) + } + releaseRetention() + throw error + } + this.retainedBytes += data.length return handle } @@ -130,8 +221,8 @@ export class CdpPdfStreamStore { const requestedSize = finiteNumber(params.size) const size = requestedSize !== null && requestedSize > 0 - ? Math.floor(requestedSize) - : PDF_STREAM_CHUNK_BYTES + ? Math.min(Math.floor(requestedSize), this.maxReadChunkBytes) + : this.maxReadChunkBytes const start = Math.min(stream.offset, stream.data.length) const end = Math.min(start + size, stream.data.length) const chunk = stream.data.subarray(start, end) @@ -146,10 +237,9 @@ export class CdpPdfStreamStore { } clear(): void { - for (const stream of this.streams.values()) { - clearTimeout(stream.cleanupTimer) + for (const handle of this.streams.keys()) { + this.delete(handle) } - this.streams.clear() } private scheduleCleanup(handle: string): ReturnType { @@ -173,5 +263,7 @@ export class CdpPdfStreamStore { } clearTimeout(stream.cleanupTimer) this.streams.delete(handle) + this.retainedBytes = Math.max(0, this.retainedBytes - stream.data.length) + stream.releaseRetention() } } diff --git a/src/main/browser/cdp-screenshot.test.ts b/src/main/browser/cdp-screenshot.test.ts index 97859f7297f..23409d48598 100644 --- a/src/main/browser/cdp-screenshot.test.ts +++ b/src/main/browser/cdp-screenshot.test.ts @@ -1,9 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { captureFullPageScreenshot, captureScreenshot } from './cdp-screenshot' +import { resetBrowserScreenshotAdmissionForTests } from './browser-screenshot-admission' +import { + BROWSER_SCREENSHOT_BUSY_ERROR, + BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES, + BROWSER_SCREENSHOT_MAX_DIMENSION_PX, + BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR +} from './browser-screenshot-limits' function createMockWebContents() { return { + once: vi.fn(), + removeListener: vi.fn(), isDestroyed: vi.fn(() => false), invalidate: vi.fn(), capturePage: vi.fn(), @@ -16,6 +25,7 @@ function createMockWebContents() { describe('captureScreenshot', () => { afterEach(() => { + resetBrowserScreenshotAdmissionForTests() vi.useRealTimers() }) @@ -36,6 +46,102 @@ describe('captureScreenshot', () => { expect(onError).not.toHaveBeenCalled() }) + it('rejects concurrent capture overload without retaining another native promise', async () => { + const webContents = createMockWebContents() + const resolvers: ((result: { data: string }) => void)[] = [] + webContents.debugger.sendCommand.mockImplementation( + () => + new Promise<{ data: string }>((resolve) => { + resolvers.push(resolve) + }) + ) + const results = Array.from({ length: BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES + 1 }, () => + vi.fn() + ) + const errors = results.map(() => vi.fn()) + + for (let index = 0; index < results.length; index += 1) { + captureScreenshot(webContents as never, { format: 'png' }, results[index]!, errors[index]!) + } + await Promise.resolve() + await Promise.resolve() + + expect(webContents.debugger.sendCommand).toHaveBeenCalledTimes( + BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES + ) + expect(errors.at(-1)).toHaveBeenCalledWith(BROWSER_SCREENSHOT_BUSY_ERROR) + for (const resolve of resolvers) { + resolve({ data: 'cG5n' }) + } + await Promise.resolve() + await Promise.resolve() + expect( + results + .slice(0, BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES) + .every((callback) => callback.mock.calls.length === 1) + ).toBe(true) + }) + + it('keeps timed-out native captures admitted until Chromium actually settles', async () => { + vi.useFakeTimers() + const webContents = createMockWebContents() + webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) + webContents.capturePage.mockResolvedValue({ + isEmpty: () => false, + getSize: () => ({ width: 800, height: 600 }), + toPNG: () => Buffer.from('fallback-png') + }) + const results = Array.from({ length: BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES }, () => + vi.fn() + ) + const errors = results.map(() => vi.fn()) + + for (let index = 0; index < results.length; index += 1) { + captureScreenshot(webContents as never, { format: 'png' }, results[index]!, errors[index]!) + } + await vi.advanceTimersByTimeAsync(8000) + + expect(results.every((callback) => callback.mock.calls.length === 0)).toBe(true) + expect( + errors.every((callback) => callback.mock.calls[0]?.[0] === BROWSER_SCREENSHOT_BUSY_ERROR) + ).toBe(true) + expect(webContents.capturePage).not.toHaveBeenCalled() + const overloadError = vi.fn() + captureScreenshot(webContents as never, { format: 'png' }, vi.fn(), overloadError) + expect(overloadError).toHaveBeenCalledWith(BROWSER_SCREENSHOT_BUSY_ERROR) + expect(webContents.debugger.sendCommand).toHaveBeenCalledTimes( + BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES + ) + }) + + it('retains admission for a fallback that outlives the failed CDP capture', async () => { + vi.useFakeTimers() + const webContents = createMockWebContents() + const commandRejectors: ((error: Error) => void)[] = [] + webContents.debugger.sendCommand.mockImplementation( + () => + new Promise((_, reject) => { + commandRejectors.push(reject) + }) + ) + webContents.capturePage.mockImplementation(() => new Promise(() => {})) + const firstError = vi.fn() + + captureScreenshot(webContents as never, { format: 'png' }, vi.fn(), firstError) + await vi.advanceTimersByTimeAsync(8000) + commandRejectors[0]!(new Error('CDP failed after timeout')) + await Promise.resolve() + await Promise.resolve() + + expect(firstError).toHaveBeenCalledWith('CDP failed after timeout') + const secondError = vi.fn() + captureScreenshot(webContents as never, { format: 'png' }, vi.fn(), secondError) + await vi.advanceTimersByTimeAsync(8000) + + expect(secondError).toHaveBeenCalledWith(BROWSER_SCREENSHOT_BUSY_ERROR) + expect(webContents.capturePage).toHaveBeenCalledOnce() + }) + it('falls back to capturePage when Page.captureScreenshot stalls', async () => { vi.useFakeTimers() @@ -43,6 +149,7 @@ describe('captureScreenshot', () => { webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) webContents.capturePage.mockResolvedValueOnce({ isEmpty: () => false, + getSize: () => ({ width: 800, height: 600 }), toPNG: () => Buffer.from('fallback-png') }) const onResult = vi.fn() @@ -63,6 +170,7 @@ describe('captureScreenshot', () => { const croppedImage = { isEmpty: () => false, + getSize: () => ({ width: 60, height: 80 }), toPNG: () => Buffer.from('cropped-png') } const webContents = createMockWebContents() @@ -127,6 +235,47 @@ describe('captureScreenshot', () => { ) }) + it('rejects an oversized clip before asking Chromium to capture it', () => { + const webContents = createMockWebContents() + const onResult = vi.fn() + const onError = vi.fn() + + captureScreenshot( + webContents as never, + { + format: 'png', + clip: { x: 0, y: 0, width: BROWSER_SCREENSHOT_MAX_DIMENSION_PX + 1, height: 1 } + }, + onResult, + onError + ) + + expect(onError).toHaveBeenCalledWith(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + expect(onResult).not.toHaveBeenCalled() + expect(webContents.debugger.sendCommand).not.toHaveBeenCalled() + }) + + it('rejects an oversized fallback bitmap before encoding it', async () => { + vi.useFakeTimers() + const toPNG = vi.fn(() => Buffer.from('unused')) + const webContents = createMockWebContents() + webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) + webContents.capturePage.mockResolvedValueOnce({ + isEmpty: () => false, + getSize: () => ({ width: BROWSER_SCREENSHOT_MAX_DIMENSION_PX + 1, height: 1 }), + toPNG + }) + const onResult = vi.fn() + const onError = vi.fn() + + captureScreenshot(webContents as never, { format: 'png' }, onResult, onError) + await vi.advanceTimersByTimeAsync(8000) + + expect(onError).toHaveBeenCalledWith(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + expect(onResult).not.toHaveBeenCalled() + expect(toPNG).not.toHaveBeenCalled() + }) + it('ignores the fallback result when CDP settles first after the timeout fires', async () => { vi.useFakeTimers() @@ -237,6 +386,11 @@ describe('captureScreenshot', () => { }) describe('captureFullPageScreenshot', () => { + afterEach(() => { + resetBrowserScreenshotAdmissionForTests() + vi.useRealTimers() + }) + it('uses cssContentSize so HiDPI pages are captured at the real page size', async () => { const webContents = createMockWebContents() webContents.debugger.sendCommand.mockImplementation((method: string) => { @@ -288,4 +442,43 @@ describe('captureFullPageScreenshot', () => { clip: { x: 0, y: 0, width: 800, height: 1600, scale: 1 } }) }) + + it('rejects oversized full-page layout bounds before capture allocation', async () => { + const webContents = createMockWebContents() + webContents.debugger.sendCommand.mockResolvedValueOnce({ + cssContentSize: { width: BROWSER_SCREENSHOT_MAX_DIMENSION_PX + 1, height: 1 } + }) + + await expect(captureFullPageScreenshot(webContents as never, 'png')).rejects.toThrow( + BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR + ) + expect(webContents.debugger.sendCommand).toHaveBeenCalledOnce() + expect(webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.getLayoutMetrics', {}) + }) + + it('bounds hung full-page metric commands across logical timeouts', async () => { + vi.useFakeTimers() + const webContents = createMockWebContents() + webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) + const active = Array.from({ length: BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES }, () => + captureFullPageScreenshot(webContents as never, 'png') + ) + const activeAssertions = active.map((capture) => + expect(capture).rejects.toThrow( + 'Screenshot timed out — the browser tab may not be visible or the window may not have focus.' + ) + ) + + await expect(captureFullPageScreenshot(webContents as never, 'png')).rejects.toThrow( + BROWSER_SCREENSHOT_BUSY_ERROR + ) + await vi.advanceTimersByTimeAsync(8000) + await Promise.all(activeAssertions) + await expect(captureFullPageScreenshot(webContents as never, 'png')).rejects.toThrow( + BROWSER_SCREENSHOT_BUSY_ERROR + ) + expect(webContents.debugger.sendCommand).toHaveBeenCalledTimes( + BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES + ) + }) }) diff --git a/src/main/browser/cdp-screenshot.ts b/src/main/browser/cdp-screenshot.ts index 7ba4315d7d9..2abba87c152 100644 --- a/src/main/browser/cdp-screenshot.ts +++ b/src/main/browser/cdp-screenshot.ts @@ -1,83 +1,20 @@ import type { WebContents } from 'electron' +import { + assertBrowserScreenshotBase64, + assertBrowserScreenshotGeometry, + BROWSER_SCREENSHOT_BUSY_ERROR, + BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR +} from './browser-screenshot-limits' +import { + startBrowserFallbackCapture, + startBrowserScreenshotCommand +} from './browser-screenshot-admission' +import { encodeNativeImageScreenshot } from './native-image-screenshot-encoder' const SCREENSHOT_TIMEOUT_MS = 8000 const FALLBACK_CAPTURE_TIMEOUT_MS = 1000 const SCREENSHOT_TIMEOUT_MESSAGE = 'Screenshot timed out — the browser tab may not be visible or the window may not have focus.' - -function applyFallbackClip( - image: Electron.NativeImage, - params: Record | undefined -): Electron.NativeImage | null { - if (params?.captureBeyondViewport) { - // Why: capturePage() can only see the currently painted viewport. If the - // caller asked for beyond-viewport pixels, returning a viewport-sized image - // would silently lie about what was captured. - return null - } - - const clip = params?.clip - if (!clip || typeof clip !== 'object') { - return image - } - const clipRect = clip as Record - - const x = typeof clipRect.x === 'number' ? clipRect.x : Number.NaN - const y = typeof clipRect.y === 'number' ? clipRect.y : Number.NaN - const width = typeof clipRect.width === 'number' ? clipRect.width : Number.NaN - const height = typeof clipRect.height === 'number' ? clipRect.height : Number.NaN - const scale = - typeof clipRect.scale === 'number' && Number.isFinite(clipRect.scale) && clipRect.scale > 0 - ? clipRect.scale - : 1 - - if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) { - return null - } - - const cropRect = { - x: Math.round(x * scale), - y: Math.round(y * scale), - width: Math.round(width * scale), - height: Math.round(height * scale) - } - const imageSize = image.getSize() - if ( - cropRect.x < 0 || - cropRect.y < 0 || - cropRect.width <= 0 || - cropRect.height <= 0 || - cropRect.x + cropRect.width > imageSize.width || - cropRect.y + cropRect.height > imageSize.height - ) { - return null - } - - return image.crop(cropRect) -} - -function encodeNativeImageScreenshot( - image: Electron.NativeImage, - params: Record | undefined -): { data: string } | null { - if (image.isEmpty()) { - return null - } - - const clippedImage = applyFallbackClip(image, params) - if (!clippedImage || clippedImage.isEmpty()) { - return null - } - - const format = params?.format === 'jpeg' ? 'jpeg' : 'png' - const quality = - typeof params?.quality === 'number' && Number.isFinite(params.quality) - ? Math.max(0, Math.min(100, Math.round(params.quality))) - : undefined - const buffer = format === 'jpeg' ? clippedImage.toJPEG(quality ?? 90) : clippedImage.toPNG() - return { data: buffer.toString('base64') } -} - function getLayoutClip(metrics: { cssContentSize?: { width?: number; height?: number } contentSize?: { width?: number; height?: number } @@ -100,25 +37,31 @@ function getLayoutClip(metrics: { return null } - return { + const clip = { x: 0, y: 0, width: Math.ceil(width), height: Math.ceil(height), scale: 1 } + assertBrowserScreenshotGeometry(clip.width, clip.height) + return clip } async function sendCommandWithTimeout( webContents: WebContents, - method: string, + method: 'Page.captureScreenshot' | 'Page.getLayoutMetrics', params: Record | undefined, timeoutMessage: string ): Promise { + const command = startBrowserScreenshotCommand(webContents, method, params ?? {}) + if (!command) { + throw new Error(BROWSER_SCREENSHOT_BUSY_ERROR) + } let timer: NodeJS.Timeout | null = null try { return await Promise.race([ - webContents.debugger.sendCommand(method, params ?? {}) as Promise, + command, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(timeoutMessage)), SCREENSHOT_TIMEOUT_MS) }) @@ -160,17 +103,27 @@ export async function captureFullPageScreenshot( const { data } = await sendCommandWithTimeout<{ data: string }>( webContents, 'Page.captureScreenshot', - { - format, - captureBeyondViewport: true, - clip - }, + { format, captureBeyondViewport: true, clip }, SCREENSHOT_TIMEOUT_MESSAGE ) + assertBrowserScreenshotBase64(data) return { data, format } } +function assertCaptureClipWithinLimit(params: Record | undefined): void { + const clip = params?.clip + if (!clip || typeof clip !== 'object') { + return + } + const values = clip as Record + if (typeof values.width !== 'number' || typeof values.height !== 'number') { + return + } + const scale = typeof values.scale === 'number' ? values.scale : 1 + assertBrowserScreenshotGeometry(values.width, values.height, scale) +} + // Why: Electron's capturePage() is unreliable on webview guests — the compositor // may not produce frames when the webview panel is inactive, unfocused, or in a // split-pane layout. Instead, use the debugger's Page.captureScreenshot which @@ -192,7 +145,12 @@ export function captureScreenshot( onError('Debugger not attached') return } - + try { + assertCaptureClipWithinLimit(params) + } catch (error) { + onError(error instanceof Error ? error.message : BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + return + } const screenshotParams: Record = {} if (params?.format) { screenshotParams.format = params.format @@ -209,6 +167,15 @@ export function captureScreenshot( if (params?.fromSurface != null) { screenshotParams.fromSurface = params.fromSurface } + const capture = startBrowserScreenshotCommand( + webContents, + 'Page.captureScreenshot', + screenshotParams + ) + if (!capture) { + onError(BROWSER_SCREENSHOT_BUSY_ERROR) + return + } let settled = false let timeoutTimer: ReturnType | null = null @@ -227,6 +194,16 @@ export function captureScreenshot( if (settled) { return } + const data = + result && typeof result === 'object' ? (result as { data?: unknown }).data : undefined + try { + if (typeof data === 'string') { + assertBrowserScreenshotBase64(data) + } + } catch (error) { + settleError(error instanceof Error ? error.message : BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) + return + } settled = true clearTimers() onResult(result) @@ -257,42 +234,48 @@ export function captureScreenshot( () => settleError(SCREENSHOT_TIMEOUT_MESSAGE), FALLBACK_CAPTURE_TIMEOUT_MS ) - void Promise.resolve() - .then(() => webContents.capturePage()) - .then( - (image) => { - if (settled) { - return - } - if (fallbackTimer) { - clearTimeout(fallbackTimer) - fallbackTimer = null - } - let fallback: { data: string } | null = null - try { - fallback = encodeNativeImageScreenshot(image, params) - } catch { - settleError(SCREENSHOT_TIMEOUT_MESSAGE) - return - } - if (fallback) { - settleResult(fallback) - return - } - settleError(SCREENSHOT_TIMEOUT_MESSAGE) - }, - () => { - if (fallbackTimer) { - clearTimeout(fallbackTimer) - fallbackTimer = null - } - settleError(SCREENSHOT_TIMEOUT_MESSAGE) + const fallbackCapture = startBrowserFallbackCapture(webContents) + if (!fallbackCapture) { + settleError(BROWSER_SCREENSHOT_BUSY_ERROR) + return + } + void fallbackCapture.then( + (image) => { + if (settled) { + return } - ) + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + let fallback: { data: string } | null = null + try { + fallback = encodeNativeImageScreenshot(image, params) + } catch (error) { + settleError( + error instanceof Error && error.message === BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR + ? error.message + : SCREENSHOT_TIMEOUT_MESSAGE + ) + return + } + if (fallback) { + settleResult(fallback) + return + } + settleError(SCREENSHOT_TIMEOUT_MESSAGE) + }, + () => { + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + settleError(SCREENSHOT_TIMEOUT_MESSAGE) + } + ) }, SCREENSHOT_TIMEOUT_MS) - dbg - .sendCommand('Page.captureScreenshot', screenshotParams) + void capture .then((result) => settleResult(result)) .catch((err) => settleError((err as Error).message)) } diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 5c366eda727..522efddf022 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import WebSocket from 'ws' -import { CdpWsProxy } from './cdp-ws-proxy' +import { + CDP_PROXY_MAX_MESSAGE_BYTES, + CDP_PROXY_MAX_TCP_CONNECTIONS, + CdpWsProxy +} from './cdp-ws-proxy' import { connect, createMockWebContents, @@ -52,6 +56,16 @@ describe('CdpWsProxy', () => { expect(server.listenerCount('error')).toBeLessThanOrEqual(1) }) + it('caps pre-upgrade sockets and WebSocket message materialization', () => { + const internals = proxy as unknown as { + httpServer: { maxConnections: number } + wss: { options: { maxPayload: number } } + } + + expect(internals.httpServer.maxConnections).toBe(CDP_PROXY_MAX_TCP_CONNECTIONS) + expect(internals.wss.options.maxPayload).toBe(CDP_PROXY_MAX_MESSAGE_BYTES) + }) + it('attaches debugger on start', () => { expect(mock.webContents.debugger.attach).toHaveBeenCalledWith('1.3') }) @@ -744,6 +758,37 @@ describe('CdpWsProxy', () => { createSpy.mockRestore() }) + it('rejects PDF print overload instead of retaining unlimited native promises', async () => { + const resolvePrints: ((buffer: Buffer) => void)[] = [] + mock.webContents.printToPDF.mockImplementation( + () => + new Promise>((resolve) => { + resolvePrints.push(resolve) + }) + ) + const client = await connect(endpoint) + const responses: Record[] = [] + client.on('message', (data) => responses.push(JSON.parse(data.toString()))) + + for (let id = 31; id <= 33; id += 1) { + client.send(JSON.stringify({ id, method: 'Page.printToPDF' })) + } + + await vi.waitFor(() => + expect(responses.find((response) => response.id === 33)).toEqual({ + id: 33, + error: { code: -32000, message: 'Too many PDF print requests are already running' } + }) + ) + expect(mock.webContents.printToPDF).toHaveBeenCalledTimes(2) + + for (const resolve of resolvePrints) { + resolve(Buffer.from('%PDF-bounded')) + } + await vi.waitFor(() => expect(responses).toHaveLength(3)) + client.close() + }) + it('forwards non-PDF IO streams to the debugger', async () => { mock.webContents.debugger.sendCommand .mockResolvedValueOnce({ data: 'trace-data', eof: false }) diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 99c1d554654..1e7277d2598 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -3,11 +3,18 @@ import { WebSocketServer, WebSocket } from 'ws' import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http' import type { WebContents } from 'electron' import { captureScreenshot } from './cdp-screenshot' -import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' +import { + assertCdpPdfWithinMemoryLimit, + buildPrintToPdfOptions, + CdpPdfStreamStore +} from './cdp-print-to-pdf' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' +import { BROWSER_PDF_BUSY_ERROR, startBrowserPdfPrint } from './browser-pdf-admission' const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000 +export const CDP_PROXY_MAX_MESSAGE_BYTES = 16 * 1024 * 1024 +export const CDP_PROXY_MAX_TCP_CONNECTIONS = 16 export class CdpWsProxy { // Why: holds each session's last DOM.focus params to replay right before the next @@ -40,7 +47,12 @@ export class CdpWsProxy { await this.attachDebugger() return new Promise((resolve, reject) => { this.httpServer = createServer((req, res) => this.handleHttpRequest(req, res)) - this.wss = new WebSocketServer({ server: this.httpServer }) + // Why: raw sockets that never upgrade must not bypass the proxy's single active WebSocket. + this.httpServer.maxConnections = CDP_PROXY_MAX_TCP_CONNECTIONS + this.wss = new WebSocketServer({ + server: this.httpServer, + maxPayload: CDP_PROXY_MAX_MESSAGE_BYTES + }) const failStart = (error: Error): void => { this.httpServer?.removeListener('error', onListenError) this.wss?.close() @@ -635,8 +647,13 @@ export class CdpWsProxy { this.sendError(clientId, 'Browser tab is no longer available', client) return } + const print = startBrowserPdfPrint(this.webContents, buildPrintToPdfOptions(params)) + if (!print) { + this.sendError(clientId, BROWSER_PDF_BUSY_ERROR, client) + return + } try { - const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params)) + const pdf = await print // Why: printToPDF can resolve after the client disconnected (or was // replaced). Bail before registering a stream so its buffer isn't // orphaned in pdfStreams past the disconnect's clear() until the TTL. @@ -644,6 +661,7 @@ export class CdpWsProxy { return } const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf) + assertCdpPdfWithinMemoryLimit(buffer) if (params.transferMode === 'ReturnAsStream') { const handle = this.pdfStreams.create(buffer) this.sendResult(clientId, { data: '', stream: handle }, client) diff --git a/src/main/browser/installed-browser-cookie-store-limits.test.ts b/src/main/browser/installed-browser-cookie-store-limits.test.ts new file mode 100644 index 00000000000..6dad57cd4d3 --- /dev/null +++ b/src/main/browser/installed-browser-cookie-store-limits.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + assertInstalledBrowserCookieStoreWithinLimits, + INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES, + INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES, + InstalledBrowserCookieStoreLimitError +} from './installed-browser-cookie-store-limits' + +describe('assertInstalledBrowserCookieStoreWithinLimits', () => { + it('accepts the exact cookie-count and byte boundaries', () => { + expect( + assertInstalledBrowserCookieStoreWithinLimits( + BigInt(INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES), + BigInt(INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES) + ) + ).toBe(INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES) + }) + + it.each([ + { + kind: 'cookies', + cookies: INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES + 1, + bytes: INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES + }, + { + kind: 'bytes', + cookies: INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES, + bytes: INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES + 1 + } + ])('rejects one $kind over its boundary', ({ kind, cookies, bytes }) => { + expect(() => assertInstalledBrowserCookieStoreWithinLimits(cookies, bytes)).toThrow( + expect.objectContaining({ + name: InstalledBrowserCookieStoreLimitError.name, + kind + }) + ) + }) +}) diff --git a/src/main/browser/installed-browser-cookie-store-limits.ts b/src/main/browser/installed-browser-cookie-store-limits.ts new file mode 100644 index 00000000000..be62a7f82fe --- /dev/null +++ b/src/main/browser/installed-browser-cookie-store-limits.ts @@ -0,0 +1,41 @@ +export const INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES = 250_000 +export const INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES = 64 * 1024 * 1024 + +export type InstalledBrowserCookieStoreLimitKind = 'cookies' | 'bytes' + +export class InstalledBrowserCookieStoreLimitError extends Error { + constructor( + readonly kind: InstalledBrowserCookieStoreLimitKind, + readonly observed: number | bigint, + readonly limit: number + ) { + super(`Installed browser cookie store exceeds the ${kind} limit`) + this.name = 'InstalledBrowserCookieStoreLimitError' + } +} + +export function assertInstalledBrowserCookieStoreWithinLimits( + cookieCount: number | bigint, + cookieBytes: number | bigint +): number { + enforceLimit('cookies', cookieCount, INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES) + enforceLimit('bytes', cookieBytes, INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES) + return Number(cookieCount) +} + +export function installedBrowserCookieStoreLimitReason(browserLabel: string): string { + return `${browserLabel} cookie store is too large to import safely (${INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES.toLocaleString('en-US')}-cookie and ${INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES / 1024 / 1024} MiB cookie-data limits).` +} + +function enforceLimit( + kind: InstalledBrowserCookieStoreLimitKind, + observed: number | bigint, + limit: number +): void { + if ( + (typeof observed === 'bigint' && observed > BigInt(limit)) || + (typeof observed === 'number' && (!Number.isSafeInteger(observed) || observed > limit)) + ) { + throw new InstalledBrowserCookieStoreLimitError(kind, observed, limit) + } +} diff --git a/src/main/browser/native-image-screenshot-encoder.ts b/src/main/browser/native-image-screenshot-encoder.ts new file mode 100644 index 00000000000..48a326feb4c --- /dev/null +++ b/src/main/browser/native-image-screenshot-encoder.ts @@ -0,0 +1,79 @@ +import { + assertBrowserScreenshotEncodedBytes, + assertBrowserScreenshotGeometry +} from './browser-screenshot-limits' + +function applyFallbackClip( + image: Electron.NativeImage, + params: Record | undefined +): Electron.NativeImage | null { + if (params?.captureBeyondViewport) { + // Why: capturePage cannot produce pixels outside the currently painted viewport. + return null + } + + const clip = params?.clip + if (!clip || typeof clip !== 'object') { + return image + } + const clipRect = clip as Record + + const x = typeof clipRect.x === 'number' ? clipRect.x : Number.NaN + const y = typeof clipRect.y === 'number' ? clipRect.y : Number.NaN + const width = typeof clipRect.width === 'number' ? clipRect.width : Number.NaN + const height = typeof clipRect.height === 'number' ? clipRect.height : Number.NaN + const scale = + typeof clipRect.scale === 'number' && Number.isFinite(clipRect.scale) && clipRect.scale > 0 + ? clipRect.scale + : 1 + + if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) { + return null + } + + const cropRect = { + x: Math.round(x * scale), + y: Math.round(y * scale), + width: Math.round(width * scale), + height: Math.round(height * scale) + } + assertBrowserScreenshotGeometry(cropRect.width, cropRect.height) + const imageSize = image.getSize() + if ( + cropRect.x < 0 || + cropRect.y < 0 || + cropRect.width <= 0 || + cropRect.height <= 0 || + cropRect.x + cropRect.width > imageSize.width || + cropRect.y + cropRect.height > imageSize.height + ) { + return null + } + + return image.crop(cropRect) +} + +export function encodeNativeImageScreenshot( + image: Electron.NativeImage, + params: Record | undefined +): { data: string } | null { + if (image.isEmpty()) { + return null + } + + const clippedImage = applyFallbackClip(image, params) + if (!clippedImage || clippedImage.isEmpty()) { + return null + } + const clippedSize = clippedImage.getSize() + assertBrowserScreenshotGeometry(clippedSize.width, clippedSize.height) + + const format = params?.format === 'jpeg' ? 'jpeg' : 'png' + const quality = + typeof params?.quality === 'number' && Number.isFinite(params.quality) + ? Math.max(0, Math.min(100, Math.round(params.quality))) + : undefined + const buffer = format === 'jpeg' ? clippedImage.toJPEG(quality ?? 90) : clippedImage.toPNG() + assertBrowserScreenshotEncodedBytes(buffer.length) + return { data: buffer.toString('base64') } +} diff --git a/src/main/browser/safari-cookie-store-decoder.test.ts b/src/main/browser/safari-cookie-store-decoder.test.ts new file mode 100644 index 00000000000..5ec0e09be33 --- /dev/null +++ b/src/main/browser/safari-cookie-store-decoder.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { + decodeSafariCookieStore, + SAFARI_COOKIE_STORE_MAX_COOKIES, + SAFARI_COOKIE_STORE_MAX_FILE_BYTES, + SAFARI_COOKIE_STORE_MAX_PAGES, + SAFARI_COOKIE_STORE_MAX_PARSED_BYTES, + SafariCookieStoreLimitError, + removeExpiredSafariCookiesInPlace, + type SafariCookieDecodeLimits +} from './safari-cookie-store-decoder' + +type TestCookie = { + domain: string + name: string + value: string + path?: string + secure?: boolean + httpOnly?: boolean + expiration?: number +} + +function buildCookie(input: TestCookie): Buffer { + const strings = [input.domain, input.name, input.path ?? '/', input.value] + let cursor = 48 + const offsets = strings.map((text) => { + const offset = cursor + cursor += Buffer.byteLength(text) + 1 + return offset + }) + const cookie = Buffer.alloc(cursor) + cookie.writeUInt32LE(cookie.length, 0) + cookie.writeUInt32LE((input.secure ? 1 : 0) | (input.httpOnly ? 4 : 0), 8) + cookie.writeUInt32LE(offsets[0]!, 16) + cookie.writeUInt32LE(offsets[1]!, 20) + cookie.writeUInt32LE(offsets[2]!, 24) + cookie.writeUInt32LE(offsets[3]!, 28) + cookie.writeDoubleLE(input.expiration ?? 0, 40) + strings.forEach((text, index) => cookie.write(text, offsets[index]!, 'utf8')) + return cookie +} + +function buildPage(inputs: TestCookie[]): Buffer { + const cookies = inputs.map(buildCookie) + const headerBytes = 8 + cookies.length * 4 + const page = Buffer.alloc(headerBytes + cookies.reduce((sum, cookie) => sum + cookie.length, 0)) + page.writeUInt32BE(0x00000100, 0) + page.writeUInt32LE(cookies.length, 4) + let cursor = headerBytes + cookies.forEach((cookie, index) => { + page.writeUInt32LE(cursor, 8 + index * 4) + cookie.copy(page, cursor) + cursor += cookie.length + }) + return page +} + +function buildStore(pages: TestCookie[][]): Buffer { + const encodedPages = pages.map(buildPage) + const headerBytes = 8 + encodedPages.length * 4 + const store = Buffer.alloc(headerBytes + encodedPages.reduce((sum, page) => sum + page.length, 0)) + store.write('cook', 0, 'utf8') + store.writeUInt32BE(encodedPages.length, 4) + let cursor = headerBytes + encodedPages.forEach((page, index) => { + store.writeUInt32BE(page.length, 8 + index * 4) + page.copy(store, cursor) + cursor += page.length + }) + return store +} + +function limits(overrides: Partial = {}): SafariCookieDecodeLimits { + return { + maxPages: 10, + maxCookies: 10, + maxParsedBytes: 1024, + ...overrides + } +} + +function retainedBytes(cookie: TestCookie): number { + const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain + const url = `${cookie.secure ? 'https' : 'http'}://${cleanDomain}/` + return [url, cookie.domain, cookie.name, cookie.value, cookie.path ?? '/'].reduce( + (sum, value) => sum + Buffer.byteLength(value), + 0 + ) +} + +describe('decodeSafariCookieStore', () => { + it('preserves page order and removes expired cookies without a second retained list', () => { + const first = { + domain: '.first.example.com', + name: 'first', + value: 'one', + secure: true, + httpOnly: true + } + const expired = { + domain: '.expired.example.com', + name: 'expired', + value: 'old', + expiration: 1 + } + const last = { domain: '.last.example.com', name: 'last', value: 'three' } + + const result = decodeSafariCookieStore(buildStore([[first, expired], [last]])) + const originalList = result.cookies + removeExpiredSafariCookiesInPlace(result.cookies, 2_000_000_000) + + expect(result.totalCookies).toBe(3) + expect(result.cookies).toBe(originalList) + expect(result.cookies.map((cookie) => cookie.name)).toEqual(['first', 'last']) + expect(result.cookies[0]).toMatchObject({ + url: 'https://first.example.com/', + domain: '.first.example.com', + value: 'one', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'unspecified' + }) + }) + + it('accepts the exact cookie and parsed-byte boundaries', () => { + const first = { domain: '.a.example.com', name: 'a', value: 'one' } + const second = { domain: '.b.example.com', name: 'b', value: 'two' } + const maxParsedBytes = retainedBytes(first) + retainedBytes(second) + + const result = decodeSafariCookieStore( + buildStore([[first, second]]), + limits({ maxCookies: 2, maxParsedBytes }) + ) + + expect(result.cookies.map((cookie) => cookie.name)).toEqual(['a', 'b']) + }) + + it('rejects one cookie or parsed byte over the configured boundary', () => { + const first = { domain: '.a.example.com', name: 'a', value: 'one' } + const second = { domain: '.b.example.com', name: 'b', value: 'two' } + const store = buildStore([[first, second]]) + + expect(() => decodeSafariCookieStore(store, limits({ maxCookies: 1 }))).toThrow( + expect.objectContaining({ kind: 'cookies', observed: 2, limit: 1 }) + ) + expect(() => + decodeSafariCookieStore( + store, + limits({ maxParsedBytes: retainedBytes(first) + retainedBytes(second) - 1 }) + ) + ).toThrow( + expect.objectContaining({ + kind: 'parsed-bytes', + observed: retainedBytes(first) + retainedBytes(second) + }) + ) + }) + + it('counts malformed declared entries against the parse budget', () => { + const malformedPage = Buffer.alloc(20) + malformedPage.writeUInt32BE(0x00000100, 0) + malformedPage.writeUInt32LE(3, 4) + const header = Buffer.alloc(12) + header.write('cook', 0, 'utf8') + header.writeUInt32BE(1, 4) + header.writeUInt32BE(malformedPage.length, 8) + + expect(() => + decodeSafariCookieStore(Buffer.concat([header, malformedPage]), limits({ maxCookies: 2 })) + ).toThrow(SafariCookieStoreLimitError) + }) + + it('publishes generous production limits', () => { + expect({ + fileBytes: SAFARI_COOKIE_STORE_MAX_FILE_BYTES, + pages: SAFARI_COOKIE_STORE_MAX_PAGES, + cookies: SAFARI_COOKIE_STORE_MAX_COOKIES, + parsedBytes: SAFARI_COOKIE_STORE_MAX_PARSED_BYTES + }).toEqual({ + fileBytes: 64 * 1024 * 1024, + pages: 65_536, + cookies: 250_000, + parsedBytes: 64 * 1024 * 1024 + }) + }) +}) diff --git a/src/main/browser/safari-cookie-store-decoder.ts b/src/main/browser/safari-cookie-store-decoder.ts new file mode 100644 index 00000000000..b6e58fc3b49 --- /dev/null +++ b/src/main/browser/safari-cookie-store-decoder.ts @@ -0,0 +1,212 @@ +const MAC_EPOCH_DELTA_SECONDS = 978_307_200 + +export const SAFARI_COOKIE_STORE_MAX_FILE_BYTES = 64 * 1024 * 1024 +export const SAFARI_COOKIE_STORE_MAX_PAGES = 65_536 +export const SAFARI_COOKIE_STORE_MAX_COOKIES = 250_000 +export const SAFARI_COOKIE_STORE_MAX_PARSED_BYTES = 64 * 1024 * 1024 + +export type SafariCookieStoreLimitKind = 'pages' | 'cookies' | 'parsed-bytes' + +export class SafariCookieStoreLimitError extends Error { + constructor( + readonly kind: SafariCookieStoreLimitKind, + readonly observed: number, + readonly limit: number + ) { + super(`Safari cookie store exceeds the ${kind} limit`) + this.name = 'SafariCookieStoreLimitError' + } +} + +export type SafariDecodedCookie = { + url: string + name: string + value: string + domain: string + path: string + secure: boolean + httpOnly: boolean + sameSite: 'unspecified' + expirationDate: number | undefined +} + +export type SafariCookieDecodeResult = { + cookies: SafariDecodedCookie[] + totalCookies: number +} + +export type SafariCookieDecodeLimits = { + maxPages: number + maxCookies: number + maxParsedBytes: number +} + +const DEFAULT_LIMITS: SafariCookieDecodeLimits = { + maxPages: SAFARI_COOKIE_STORE_MAX_PAGES, + maxCookies: SAFARI_COOKIE_STORE_MAX_COOKIES, + maxParsedBytes: SAFARI_COOKIE_STORE_MAX_PARSED_BYTES +} + +type DecodeState = { + readonly cookies: SafariDecodedCookie[] + readonly limits: SafariCookieDecodeLimits + decodedCookies: number + inspectedEntries: number + parsedBytes: number +} + +export function decodeSafariCookieStore( + buffer: Buffer, + limits: SafariCookieDecodeLimits = DEFAULT_LIMITS +): SafariCookieDecodeResult { + if (buffer.length < 8 || buffer.subarray(0, 4).toString('utf8') !== 'cook') { + return { cookies: [], totalCookies: 0 } + } + + const pageCount = buffer.readUInt32BE(4) + enforceLimit('pages', pageCount, limits.maxPages) + const pagesOffset = 8 + pageCount * 4 + if (pagesOffset > buffer.length) { + return { cookies: [], totalCookies: 0 } + } + + const state: DecodeState = { + cookies: [], + limits, + decodedCookies: 0, + inspectedEntries: 0, + parsedBytes: 0 + } + let pageOffset = pagesOffset + for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) { + const pageSize = buffer.readUInt32BE(8 + pageIndex * 4) + const pageEnd = Math.min(buffer.length, pageOffset + pageSize) + decodeSafariCookiePage(buffer.subarray(pageOffset, pageEnd), state) + pageOffset += pageSize + } + return { cookies: state.cookies, totalCookies: state.decodedCookies } +} + +function decodeSafariCookiePage(page: Buffer, state: DecodeState): void { + if (page.length < 16 || page.readUInt32BE(0) !== 0x00000100) { + return + } + + const cookieCount = page.readUInt32LE(4) + state.inspectedEntries += cookieCount + enforceLimit('cookies', state.inspectedEntries, state.limits.maxCookies) + if (8 + cookieCount * 4 > page.length) { + return + } + + for (let index = 0; index < cookieCount; index += 1) { + const offset = page.readUInt32LE(8 + index * 4) + const cookie = decodeSafariCookie(page.subarray(offset)) + if (!cookie) { + continue + } + state.decodedCookies += 1 + state.parsedBytes += cookieRetainedBytes(cookie) + enforceLimit('parsed-bytes', state.parsedBytes, state.limits.maxParsedBytes) + state.cookies.push(cookie) + } +} + +export function removeExpiredSafariCookiesInPlace( + cookies: SafariDecodedCookie[], + nowSeconds: number +): void { + let retained = 0 + for (const cookie of cookies) { + if (!cookie.expirationDate || cookie.expirationDate > nowSeconds) { + cookies[retained] = cookie + retained += 1 + } + } + cookies.length = retained +} + +function decodeSafariCookie(buffer: Buffer): SafariDecodedCookie | null { + if (buffer.length < 48) { + return null + } + // Why: the declared size is external; clamp string scans to bytes present in this page. + const size = Math.min(buffer.readUInt32LE(0), buffer.length) + if (size < 48) { + return null + } + + const flags = buffer.readUInt32LE(8) + const secure = (flags & 1) !== 0 + const name = readCString(buffer, buffer.readUInt32LE(20), size) + if (!name) { + return null + } + const value = readCString(buffer, buffer.readUInt32LE(28), size) ?? '' + const path = readCString(buffer, buffer.readUInt32LE(24), size) ?? '/' + const rawUrl = readCString(buffer, buffer.readUInt32LE(16), size) ?? '' + const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null + if (!domain) { + return null + } + + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + // Why: Safari stores expiration seconds relative to 2001-01-01. + const expiration = buffer.readDoubleLE(40) + const expirationDate = + expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA_SECONDS) : undefined + + return { + url, + name, + value, + domain, + path, + secure, + httpOnly: (flags & 4) !== 0, + sameSite: 'unspecified', + expirationDate + } +} + +function readCString(buffer: Buffer, offset: number, end: number): string | null { + if (offset < 0 || offset >= end) { + return null + } + let cursor = offset + while (cursor < end && buffer[cursor] !== 0) { + cursor += 1 + } + return cursor < end ? buffer.toString('utf8', offset, cursor) : null +} + +function deriveUrl(domain: string, secure: boolean): string | null { + const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain + if (!cleanDomain || cleanDomain.includes(' ')) { + return null + } + try { + return new URL(`${secure ? 'https' : 'http'}://${cleanDomain}/`).toString() + } catch { + return null + } +} + +function cookieRetainedBytes(cookie: SafariDecodedCookie): number { + return ( + Buffer.byteLength(cookie.url) + + Buffer.byteLength(cookie.name) + + Buffer.byteLength(cookie.value) + + Buffer.byteLength(cookie.domain) + + Buffer.byteLength(cookie.path) + ) +} + +function enforceLimit(kind: SafariCookieStoreLimitKind, observed: number, limit: number): void { + if (observed > limit) { + throw new SafariCookieStoreLimitError(kind, observed, limit) + } +} diff --git a/src/main/browser/snapshot-engine.test.ts b/src/main/browser/snapshot-engine.test.ts index 17f5341b220..c06b5d43d21 100644 --- a/src/main/browser/snapshot-engine.test.ts +++ b/src/main/browser/snapshot-engine.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest' -import { buildSnapshot, type CdpCommandSender } from './snapshot-engine' +import { + buildSnapshot, + SNAPSHOT_MAX_ENTRIES, + SNAPSHOT_MAX_NAME_CODE_UNITS, + SNAPSHOT_MAX_RETAINED_NAME_CODE_UNITS, + type CdpCommandSender +} from './snapshot-engine' type AXNode = { nodeId: string @@ -193,4 +199,46 @@ describe('buildSnapshot', () => { expect(result.snapshot).toContain('[Footer]') expect(result.snapshot).toContain('heading "Dashboard"') }) + + it('bounds retained entries and page-controlled accessible names', async () => { + const childIds = Array.from({ length: SNAPSHOT_MAX_ENTRIES + 100 }, (_, index) => + String(index + 2) + ) + const nodes: AXNode[] = [node('1', 'WebArea', 'page', { childIds })] + for (const childId of childIds) { + nodes.push( + node( + childId, + 'button', + `${childId.padStart(8, '0')}-${'x'.repeat(SNAPSHOT_MAX_NAME_CODE_UNITS + 100)}` + ) + ) + } + + const result = await buildSnapshot(makeSender(nodes)) + + expect(result.refs).toHaveLength( + Math.min( + SNAPSHOT_MAX_ENTRIES, + Math.floor(SNAPSHOT_MAX_RETAINED_NAME_CODE_UNITS / SNAPSHOT_MAX_NAME_CODE_UNITS) + ) + ) + expect(result.refs[0]?.name).toHaveLength(SNAPSHOT_MAX_NAME_CODE_UNITS) + expect(result.refs.reduce((total, ref) => total + ref.name.length, 0)).toBeLessThanOrEqual( + SNAPSHOT_MAX_RETAINED_NAME_CODE_UNITS + ) + }) + + it('walks malformed cyclic trees without recursive growth', async () => { + const nodes: AXNode[] = [ + node('1', 'WebArea', 'page', { childIds: ['2'] }), + node('2', 'generic', '', { childIds: ['3'] }), + node('3', 'generic', '', { childIds: ['2', '4'] }), + node('4', 'button', 'Reachable', { backendDOMNodeId: 4 }) + ] + + const result = await buildSnapshot(makeSender(nodes)) + + expect(result.refs).toEqual([{ ref: '@e1', role: 'button', name: 'Reachable' }]) + }) }) diff --git a/src/main/browser/snapshot-engine.ts b/src/main/browser/snapshot-engine.ts index 141a3d957dc..1d6aac01a19 100644 --- a/src/main/browser/snapshot-engine.ts +++ b/src/main/browser/snapshot-engine.ts @@ -75,6 +75,18 @@ const HEADING_PATTERN = /^heading$/ const SKIP_ROLES = new Set(['none', 'presentation', 'generic']) +export const SNAPSHOT_MAX_AX_NODES = 50_000 +export const SNAPSHOT_MAX_ENTRIES = 4096 +export const SNAPSHOT_MAX_NAME_CODE_UNITS = 1024 +export const SNAPSHOT_MAX_RETAINED_NAME_CODE_UNITS = 1024 * 1024 +const SNAPSHOT_MAX_VISUAL_DEPTH = 64 + +type SnapshotWalkBudget = { + remainingNodes: number + remainingEntries: number + remainingNameCodeUnits: number +} + export async function buildSnapshot( sendCommand: CdpCommandSender, iframeSessions?: Map, @@ -84,37 +96,50 @@ export async function buildSnapshot( const { nodes } = (await sendCommand('Accessibility.getFullAXTree')) as { nodes: AXNode[] } const nodeById = new Map() - for (const node of nodes) { + for (const node of nodes.slice(0, SNAPSHOT_MAX_AX_NODES)) { nodeById.set(node.nodeId, node) } const entries: SnapshotEntry[] = [] let refCounter = 1 + const budget: SnapshotWalkBudget = { + remainingNodes: SNAPSHOT_MAX_AX_NODES, + remainingEntries: SNAPSHOT_MAX_ENTRIES, + remainingNameCodeUnits: SNAPSHOT_MAX_RETAINED_NAME_CODE_UNITS + } const root = nodes[0] if (!root) { return { snapshot: '', refs: [], refMap: new Map() } } - walkTree(root, nodeById, 0, entries, () => refCounter++) + walkTree(root, nodeById, 0, entries, () => refCounter++, budget) // Why: many modern SPAs use styled
s, s, and custom elements as // interactive controls without proper ARIA roles. These elements are invisible // to the accessibility tree walk above but are clearly interactive (cursor:pointer, // onclick, tabindex, contenteditable). This DOM query pass discovers them and // promotes them to interactive refs so the agent can interact with them. - const cursorInteractiveEntries = await findCursorInteractiveElements(sendCommand, entries) + const cursorInteractiveEntries = + budget.remainingEntries > 0 ? await findCursorInteractiveElements(sendCommand, entries) : [] for (const cie of cursorInteractiveEntries) { + const name = reserveSnapshotName(cie.name, budget) + if (name === null || !reserveSnapshotEntry(budget)) { + break + } cie.ref = `@e${refCounter++}` - entries.push(cie) + entries.push({ ...cie, name }) } // Why: cross-origin iframes have their own AX trees accessible only through // their dedicated CDP session. Append their elements after the parent tree // so the agent can see and interact with iframe content. - const iframeRefSessions: { ref: string; sessionId: string }[] = [] + const iframeRefSessions = new Map() if (iframeSessions && makeIframeSender && iframeSessions.size > 0) { for (const [_frameId, sessionId] of iframeSessions) { + if (budget.remainingEntries <= 0 || budget.remainingNodes <= 0) { + break + } try { const iframeSender = makeIframeSender(sessionId) await iframeSender('Accessibility.enable') @@ -125,15 +150,15 @@ export async function buildSnapshot( continue } const iframeNodeById = new Map() - for (const n of iframeNodes) { + for (const n of iframeNodes.slice(0, budget.remainingNodes)) { iframeNodeById.set(n.nodeId, n) } const iframeRoot = iframeNodes[0] if (iframeRoot) { const startRef = refCounter - walkTree(iframeRoot, iframeNodeById, 1, entries, () => refCounter++) + walkTree(iframeRoot, iframeNodeById, 1, entries, () => refCounter++, budget) for (let i = startRef; i < refCounter; i++) { - iframeRefSessions.push({ ref: `@e${i}`, sessionId }) + iframeRefSessions.set(`@e${i}`, sessionId) } } } catch { @@ -171,12 +196,11 @@ export async function buildSnapshot( } lines.push(`${indent}[${entry.ref}] ${entry.role} "${displayName}"`) refs.push({ ref: entry.ref, role: entry.role, name: displayName }) - const iframeSession = iframeRefSessions.find((s) => s.ref === entry.ref) refMap.set(entry.ref, { backendDOMNodeId: entry.backendDOMNodeId, role: entry.role, name: entry.name, - sessionId: iframeSession?.sessionId, + sessionId: iframeRefSessions.get(entry.ref), nth: total > 1 ? nth : undefined }) } else { @@ -188,109 +212,161 @@ export async function buildSnapshot( } function walkTree( - node: AXNode, + root: AXNode, nodeById: Map, depth: number, entries: SnapshotEntry[], - nextRef: () => number + nextRef: () => number, + budget: SnapshotWalkBudget ): void { - if (node.ignored) { - walkChildren(node, nodeById, depth, entries, nextRef) - return - } + const stack: { node: AXNode; depth: number }[] = [{ node: root, depth }] + const visited = new Set() + while (stack.length > 0 && budget.remainingNodes > 0 && budget.remainingEntries > 0) { + const current = stack.pop()! + const node = current.node + if (visited.has(node.nodeId)) { + continue + } + visited.add(node.nodeId) + budget.remainingNodes -= 1 - const role = node.role?.value ?? '' - const name = node.name?.value ?? '' + const role = node.role?.value ?? '' + const rawName = node.name?.value ?? '' + const isInteractive = INTERACTIVE_ROLES.has(role) + const isHeading = HEADING_PATTERN.test(role) + const isLandmark = LANDMARK_ROLES.has(role) + const isStaticText = role === 'staticText' || role === 'StaticText' + const shouldWalkChildren = + node.ignored === true || + SKIP_ROLES.has(role) || + (!isInteractive && !isHeading && !isLandmark && !isStaticText) || + (!rawName && !isLandmark) - if (SKIP_ROLES.has(role)) { - walkChildren(node, nodeById, depth, entries, nextRef) - return - } + if (shouldWalkChildren) { + pushSnapshotChildren(stack, node, nodeById, current.depth, budget.remainingNodes) + continue + } - const isInteractive = INTERACTIVE_ROLES.has(role) - const isHeading = HEADING_PATTERN.test(role) - const isLandmark = LANDMARK_ROLES.has(role) - const isStaticText = role === 'staticText' || role === 'StaticText' + if (isLandmark) { + const name = reserveSnapshotName(rawName || role, budget) + if (name !== null && reserveSnapshotEntry(budget)) { + entries.push({ + ref: '', + role: formatLandmarkRole(role, rawName ? name : ''), + name, + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth: current.depth + }) + } + pushSnapshotChildren( + stack, + node, + nodeById, + Math.min(current.depth + 1, SNAPSHOT_MAX_VISUAL_DEPTH), + budget.remainingNodes + ) + continue + } - if (!isInteractive && !isHeading && !isLandmark && !isStaticText) { - walkChildren(node, nodeById, depth, entries, nextRef) - return - } + if (isHeading) { + appendSnapshotEntry(entries, budget, { + ref: '', + role: 'heading', + rawName, + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth: current.depth + }) + continue + } - if (!name && !isLandmark) { - walkChildren(node, nodeById, depth, entries, nextRef) - return - } + if (isStaticText) { + const name = trimSnapshotName(rawName) + if (name) { + appendSnapshotEntry(entries, budget, { + ref: '', + role: 'text', + rawName: name, + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth: current.depth + }) + } + continue + } - const hasFocusable = isInteractive && isFocusable(node) - - if (isLandmark) { - entries.push({ - ref: '', - role: formatLandmarkRole(role, name), - name: name || role, - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth - }) - walkChildren(node, nodeById, depth + 1, entries, nextRef) - return - } - - if (isHeading) { - entries.push({ - ref: '', - role: 'heading', - name, - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth - }) - return - } - - if (isStaticText && name.trim().length > 0) { - entries.push({ - ref: '', - role: 'text', - name: name.trim(), - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth - }) - return - } - - if (isInteractive && (hasFocusable || node.backendDOMNodeId)) { - const ref = `@e${nextRef()}` - entries.push({ - ref, - role: formatInteractiveRole(role), - name: name || '(unlabeled)', - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth - }) - return - } - - walkChildren(node, nodeById, depth, entries, nextRef) -} - -function walkChildren( - node: AXNode, - nodeById: Map, - depth: number, - entries: SnapshotEntry[], - nextRef: () => number -): void { - if (!node.childIds) { - return - } - for (const childId of node.childIds) { - const child = nodeById.get(childId) - if (child) { - walkTree(child, nodeById, depth, entries, nextRef) + if (isInteractive && (isFocusable(node) || node.backendDOMNodeId)) { + appendSnapshotEntry(entries, budget, { + ref: `@e${nextRef()}`, + role: formatInteractiveRole(role), + rawName, + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth: current.depth + }) } } } +function pushSnapshotChildren( + stack: { node: AXNode; depth: number }[], + node: AXNode, + nodeById: Map, + depth: number, + maxChildren: number +): void { + const childCount = Math.min(node.childIds?.length ?? 0, maxChildren) + for (let index = childCount - 1; index >= 0; index--) { + const child = nodeById.get(node.childIds![index]) + if (child) { + stack.push({ node: child, depth }) + } + } +} + +function appendSnapshotEntry( + entries: SnapshotEntry[], + budget: SnapshotWalkBudget, + entry: Omit & { rawName: string } +): void { + const name = reserveSnapshotName(entry.rawName, budget) + if (name === null || !reserveSnapshotEntry(budget)) { + return + } + const { rawName: _rawName, ...rest } = entry + entries.push({ ...rest, name }) +} + +function reserveSnapshotEntry(budget: SnapshotWalkBudget): boolean { + if (budget.remainingEntries <= 0) { + return false + } + budget.remainingEntries -= 1 + return true +} + +function reserveSnapshotName(rawName: string, budget: SnapshotWalkBudget): string | null { + const maxLength = Math.min(SNAPSHOT_MAX_NAME_CODE_UNITS, budget.remainingNameCodeUnits) + if (maxLength <= 0) { + return null + } + const name = rawName.slice(0, maxLength) + budget.remainingNameCodeUnits -= name.length + return name +} + +function trimSnapshotName(rawName: string): string { + let start = 0 + while (start < rawName.length && /\s/.test(rawName[start])) { + start += 1 + } + if (start === rawName.length) { + return '' + } + let end = rawName.length + while (end > start && /\s/.test(rawName[end - 1])) { + end -= 1 + } + return rawName.slice(start, Math.min(end, start + SNAPSHOT_MAX_NAME_CODE_UNITS)) +} + function isFocusable(node: AXNode): boolean { if (!node.properties) { return true diff --git a/src/main/cache-identity-digest.ts b/src/main/cache-identity-digest.ts new file mode 100644 index 00000000000..fbcbad42abe --- /dev/null +++ b/src/main/cache-identity-digest.ts @@ -0,0 +1,11 @@ +import { createHash } from 'node:crypto' + +/** Fixed-size identity for long-lived maps whose source fields can be arbitrarily large. */ +export function cacheIdentityDigest(parts: readonly string[]): string { + const digest = createHash('sha256') + for (const part of parts) { + digest.update(`${part.length}:`) + digest.update(part) + } + return digest.digest('base64url') +} diff --git a/src/main/claude-accounts/live-pty-gate.test.ts b/src/main/claude-accounts/live-pty-gate.test.ts index 7359bbd9805..8ffe4bf8b79 100644 --- a/src/main/claude-accounts/live-pty-gate.test.ts +++ b/src/main/claude-accounts/live-pty-gate.test.ts @@ -8,7 +8,6 @@ import { isClaudeAuthSwitchInProgress, markClaudePtyExited, markClaudePtySpawned, - onLiveClaudePtysDrained, seedLiveClaudePtysFromPersistence } from './live-pty-gate' @@ -78,53 +77,6 @@ describe('Claude live PTY gate', () => { expect(hasLiveClaudePtys()).toBe(true) }) - it('notifies drain listeners only when the last live Claude PTY exits', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - try { - markClaudePtySpawned('live-claude-pty') - markClaudePtySpawned('seeded-pty-1') - - markClaudePtyExited('live-claude-pty') - expect(onDrained).not.toHaveBeenCalled() - - markClaudePtyExited('seeded-pty-1') - expect(onDrained).toHaveBeenCalledTimes(1) - - // Why: exits with no live PTYs left must not fire again — the drain - // signal marks the 1 -> 0 transition, not every teardown call. - markClaudePtyExited('seeded-pty-1') - expect(onDrained).toHaveBeenCalledTimes(1) - } finally { - unsubscribe() - } - }) - - it('notifies drain listeners when seed reconciliation releases the last live id', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - try { - seedLiveClaudePtysFromPersistence(['seeded-pty-1']) - - confirmSeededClaudeLivePtys([]) - - expect(onDrained).toHaveBeenCalledTimes(1) - } finally { - unsubscribe() - } - }) - - it('stops notifying an unsubscribed drain listener', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - unsubscribe() - - markClaudePtySpawned('live-claude-pty') - markClaudePtyExited('live-claude-pty') - - expect(onDrained).not.toHaveBeenCalled() - }) - it('persists spawns and exits when persistence is attached', () => { const addClaudeLivePtySessionId = vi.fn() const removeClaudeLivePtySessionId = vi.fn() diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts index 9e30b621924..7c469d6bceb 100644 --- a/src/main/claude-accounts/live-pty-gate.ts +++ b/src/main/claude-accounts/live-pty-gate.ts @@ -17,26 +17,6 @@ export function attachClaudeLivePtyPersistence(target: ClaudeLivePtyPersistence persistence = target } -// Why: a live claude defers the managed OAuth refresh ("Waiting for Claude -// session"); consumers need the 1 -> 0 transition to recover promptly instead -// of waiting out the usage-fetch failure backoff. -type LiveClaudePtyDrainListener = () => void -const drainListeners = new Set() - -export function onLiveClaudePtysDrained(listener: LiveClaudePtyDrainListener): () => void { - drainListeners.add(listener) - return () => drainListeners.delete(listener) -} - -function notifyDrainedOnTransition(hadLivePtys: boolean): void { - if (!hadLivePtys || liveClaudePtyIds.size > 0) { - return - } - for (const listener of drainListeners) { - listener() - } -} - export function seedLiveClaudePtysFromPersistence(sessionIds: readonly string[]): void { for (const sessionId of sessionIds) { liveClaudePtyIds.add(sessionId) @@ -55,7 +35,6 @@ export function hasSeededUnconfirmedClaudePtys(): boolean { * their pane never reattaches: that daemon process still owns the credentials. */ export function confirmSeededClaudeLivePtys(aliveSessionIds: readonly string[]): void { - const hadLivePtys = liveClaudePtyIds.size > 0 const alive = new Set(aliveSessionIds) for (const sessionId of seededUnconfirmedPtyIds) { if (!alive.has(sessionId)) { @@ -64,7 +43,6 @@ export function confirmSeededClaudeLivePtys(aliveSessionIds: readonly string[]): } } seededUnconfirmedPtyIds.clear() - notifyDrainedOnTransition(hadLivePtys) } export function markClaudePtySpawned(ptyId: string): void { @@ -74,11 +52,9 @@ export function markClaudePtySpawned(ptyId: string): void { } export function markClaudePtyExited(ptyId: string): void { - const hadLivePtys = liveClaudePtyIds.size > 0 liveClaudePtyIds.delete(ptyId) seededUnconfirmedPtyIds.delete(ptyId) persistence?.removeClaudeLivePtySessionId(ptyId) - notifyDrainedOnTransition(hadLivePtys) } export function hasLiveClaudePtys(): boolean { diff --git a/src/main/claude-accounts/oauth-refresh.ts b/src/main/claude-accounts/oauth-refresh.ts index cc717ef51ee..a69c12ad04a 100644 --- a/src/main/claude-accounts/oauth-refresh.ts +++ b/src/main/claude-accounts/oauth-refresh.ts @@ -1,5 +1,6 @@ import { net, session } from 'electron' import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' // Why: the OAuth client id and token endpoint are the public Claude Code // values, verified against the installed `claude` binary (2.1.177) and the @@ -158,7 +159,7 @@ export async function refreshClaudeOauthCredentials( console.warn(`[claude-oauth-refresh] token endpoint returned ${res.status}`) return null } - const data = (await res.json()) as TokenEndpointResponse + const data = await readFetchResponseJsonWithinLimit(res) return applyRefreshedToken(credentialsJson, data, now) } catch (error) { console.warn( diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 58328222528..101626c8fdf 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,10 +1,11 @@ /* eslint-disable max-lines -- Why: keeps file/Keychain/snapshot/env-patch auth semantics together so PTY launch and quota-fetch paths can't drift. */ import { execFileSync } from 'node:child_process' -import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { app } from 'electron' import type { ClaudeManagedAccount } from '../../shared/types' import type { Store } from '../persistence' +import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader' import { writeFileAtomically } from '../codex-accounts/fs-utils' import type { ClaudeEnvPatch } from './environment' import { @@ -35,6 +36,7 @@ import { setSelectedClaudeAccountIdForTarget, type ClaudeAccountSelectionTarget } from './runtime-selection' +import { NodeFileReadTooLargeError } from '../../shared/memory-safety/node-bounded-file-reader' export type ClaudeRuntimeAuthPreparation = { configDir: string @@ -346,7 +348,7 @@ export class ClaudeRuntimeAuthService { if (this.lastSyncedAccountId === null) { const paths = this.pathResolver.getRuntimePaths() const runtimeCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') + ? readAgentStateFileSync(paths.credentialsPath) : null await this.captureSystemDefaultSnapshotForManagedEntry( runtimeCredentialsJson, @@ -569,7 +571,7 @@ export class ClaudeRuntimeAuthService { ): Promise { const paths = this.pathResolver.getRuntimePaths() const fileCredentials = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') + ? readAgentStateFileSync(paths.credentialsPath) : null const runtimeOauthAccount = this.readRuntimeOauthAccount() const candidates: ClaudeRuntimeCredentialCandidate[] = [] @@ -1162,7 +1164,7 @@ export class ClaudeRuntimeAuthService { options.credentialsJsonOverride !== undefined ? options.credentialsJsonOverride : existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') + ? readAgentStateFileSync(paths.credentialsPath) : null const keychainCredentialsJson = await this.readAggregateClaudeKeychainCredentialsBestEffort( paths.configDir @@ -1293,7 +1295,7 @@ export class ClaudeRuntimeAuthService { return null } try { - const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown + const parsed = readAgentStateJsonFileSync(snapshotPath) if (this.isSystemDefaultSnapshot(parsed)) { return parsed } @@ -1411,7 +1413,7 @@ export class ClaudeRuntimeAuthService { private readRuntimeCredentialsFile(): string | null { const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath - return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null + return existsSync(credentialsPath) ? readAgentStateFileSync(credentialsPath) : null } private runtimeCredentialsBelongToAccount( @@ -1452,7 +1454,7 @@ export class ClaudeRuntimeAuthService { } const paths = this.pathResolver.getRuntimePaths() const currentCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') + ? readAgentStateFileSync(paths.credentialsPath) : null return currentCredentialsJson === previouslyWrittenCredentialsJson } @@ -1461,7 +1463,7 @@ export class ClaudeRuntimeAuthService { const paths = this.pathResolver.getRuntimePaths() try { const currentCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') + ? readAgentStateFileSync(paths.credentialsPath) : null return ( currentCredentialsJson !== null && @@ -1551,7 +1553,7 @@ export class ClaudeRuntimeAuthService { return null } try { - const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as unknown + const parsed = readAgentStateJsonFileSync(configPath) const record = this.asRecord(parsed) if (!record) { return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR @@ -1761,8 +1763,11 @@ export class ClaudeRuntimeAuthService { private fileContentsEqual(targetPath: string, contents: string): boolean { try { - return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents - } catch { + return existsSync(targetPath) && readAgentStateFileSync(targetPath) === contents + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw error + } return false } } @@ -1783,7 +1788,7 @@ export class ClaudeRuntimeAuthService { return {} } try { - const parsed = JSON.parse(readFileSync(targetPath, 'utf-8')) as unknown + const parsed = readAgentStateJsonFileSync(targetPath) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record } diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 5e68b20d615..1b0485997bf 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -2,7 +2,7 @@ for login, credential capture, Keychain storage, selection, and rate-limit refresh. */ import { randomUUID } from 'node:crypto' import { execFileSync, spawn } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative, resolve, sep } from 'node:path' import type { @@ -12,6 +12,7 @@ import type { } from '../../shared/types' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' +import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader' import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { @@ -613,7 +614,7 @@ export class ClaudeAccountService { } } const credentialsPath = join(configDir, '.credentials.json') - return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null + return existsSync(credentialsPath) ? readAgentStateFileSync(credentialsPath) : null } private readOauthAccountFromConfigDir(configDir: string): unknown { @@ -622,7 +623,7 @@ export class ClaudeAccountService { continue } try { - const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record + const parsed = readAgentStateJsonFileSync(configPath) as Record if (parsed.oauthAccount) { return parsed.oauthAccount } diff --git a/src/main/claude-usage/scanner-large-directory.test.ts b/src/main/claude-usage/scanner-large-directory.test.ts index 78d3895c58b..8262a54da83 100644 --- a/src/main/claude-usage/scanner-large-directory.test.ts +++ b/src/main/claude-usage/scanner-large-directory.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import type { Dirent } from 'node:fs' +import type { Dir, Dirent } from 'node:fs' import type * as FsPromises from 'node:fs/promises' import type * as NodeOs from 'node:os' import { join } from 'node:path' -const { homedirMock, readdirMock } = vi.hoisted(() => ({ +const { homedirMock, opendirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>(), - readdirMock: vi.fn<(dirPath: string) => Promise>() + opendirMock: vi.fn<(dirPath: string) => Promise>() })) vi.mock('os', async () => { @@ -21,7 +21,7 @@ vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') return { ...actual, - readdir: readdirMock + opendir: opendirMock } }) @@ -43,24 +43,84 @@ const largeTranscriptEntries = Array.from({ length: FILE_COUNT }, (_, index) => dirent(`session-${index}.jsonl`, 'file') ) +function directory(entries: Dirent[]): Dir { + return { + async *[Symbol.asyncIterator]() { + yield* entries + } + } as Dir +} + +function generatedFileDirectory(count: number): Dir { + return { + async *[Symbol.asyncIterator]() { + for (let index = 0; index < count; index++) { + yield dirent(`session-${index}.jsonl`, 'file') + } + } + } as Dir +} + describe('listClaudeTranscriptFiles large directories', () => { it('keeps nested transcript scans past the JavaScript spread-argument limit', async () => { homedirMock.mockReturnValue(FAKE_HOME) - readdirMock.mockImplementation(async (dirPath) => { + opendirMock.mockImplementation(async (dirPath) => { if (dirPath === PROJECTS_ROOT) { - return [dirent('large-project', 'directory')] + return directory([dirent('large-project', 'directory')]) } if (dirPath === PROJECT_DIR) { - return largeTranscriptEntries + return directory(largeTranscriptEntries) } if (dirPath === TRANSCRIPTS_ROOT) { - return [] + return directory([]) } - throw new Error(`Unexpected readdir path: ${dirPath}`) + throw new Error(`Unexpected opendir path: ${dirPath}`) }) const { listClaudeTranscriptFiles } = await import('./scanner') await expect(listClaudeTranscriptFiles()).resolves.toHaveLength(FILE_COUNT) }) + + it('fails closed instead of silently omitting a transcript past capacity', async () => { + opendirMock.mockImplementation(async (dirPath) => { + if (dirPath === PROJECTS_ROOT) { + return generatedFileDirectory(200_001) + } + if (dirPath === TRANSCRIPTS_ROOT) { + return directory([]) + } + throw new Error(`Unexpected opendir path: ${dirPath}`) + }) + + const { listClaudeTranscriptFiles } = await import('./scanner') + + await expect(listClaudeTranscriptFiles()).rejects.toMatchObject({ + name: 'UsageHistoryScanCapacityError', + resource: 'files', + limit: 200_000 + }) + }) + + it('keeps stable transcripts when a nested directory vanishes during discovery', async () => { + const vanishedDirectory = join(PROJECTS_ROOT, 'vanished') + opendirMock.mockImplementation(async (dirPath) => { + if (dirPath === PROJECTS_ROOT) { + return directory([dirent('stable.jsonl', 'file'), dirent('vanished', 'directory')]) + } + if (dirPath === vanishedDirectory) { + throw Object.assign(new Error('directory vanished'), { code: 'ENOENT' }) + } + if (dirPath === TRANSCRIPTS_ROOT) { + return directory([]) + } + throw new Error(`Unexpected opendir path: ${dirPath}`) + }) + + const { listClaudeTranscriptFiles } = await import('./scanner') + + await expect(listClaudeTranscriptFiles()).resolves.toEqual([ + join(PROJECTS_ROOT, 'stable.jsonl') + ]) + }) }) diff --git a/src/main/claude-usage/scanner.test.ts b/src/main/claude-usage/scanner.test.ts index f7de6cef141..c04879d1684 100644 --- a/src/main/claude-usage/scanner.test.ts +++ b/src/main/claude-usage/scanner.test.ts @@ -8,6 +8,7 @@ import { import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { UsageHistoryScanBudget } from '../usage-history-scan-budget' describe('parseClaudeUsageRecord', () => { it('extracts token usage from assistant transcript lines', () => { @@ -102,6 +103,31 @@ describe('parseClaudeUsageRecord', () => { await rm(root, { recursive: true, force: true }) } }) + + it('fails the whole parse when retained usage records exceed capacity', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-capacity-')) + const filePath = join(root, 'session.jsonl') + const record = (uuid: string) => + JSON.stringify({ + type: 'assistant', + sessionId: 'session-1', + uuid, + timestamp: '2026-04-09T10:00:00.000Z', + message: { usage: { input_tokens: 1 } } + }) + try { + await writeFile(filePath, [record('turn-1'), record('turn-2')].join('\n')) + const budget = new UsageHistoryScanBudget({ records: 1 }) + + await expect(parseClaudeUsageFile(filePath, budget)).rejects.toMatchObject({ + name: 'UsageHistoryScanCapacityError', + resource: 'records', + limit: 1 + }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) describe('Claude usage aggregation', () => { diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index db1c1f21f7c..f797def80ef 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -1,10 +1,16 @@ /* eslint-disable max-lines -- Why: transcript discovery, parsing, attribution, and aggregation share one data shape pipeline. Keeping them co-located makes it easier to audit correctness when Claude usage numbers look surprising. */ import { homedir } from 'node:os' import { join, basename } from 'node:path' -import { realpath, readdir, stat } from 'node:fs/promises' -import { createReadStream } from 'node:fs' -import { createInterface } from 'node:readline' +import { realpath, stat } from 'node:fs/promises' import type { Repo } from '../../shared/types' +import { walkUsageHistoryJsonlFiles } from '../usage-history-file-discovery' +import { readUsageHistoryJsonlLines } from '../usage-history-jsonl-reader' +import { + MAX_USAGE_HISTORY_FILES, + UsageHistoryScanBudget, + UsageHistoryScanCapacityError, + getUsageHistoryRetainedBytes +} from '../usage-history-scan-budget' import type { ClaudeUsageAttributedTurn, ClaudeUsageDailyAggregate, @@ -128,54 +134,29 @@ async function yieldToEventLoop(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } -async function walkJsonlFiles(dirPath: string): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - const files: string[] = [] - - for (const entry of entries) { - const fullPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - appendDiscoveredFiles(files, await walkJsonlFiles(fullPath)) - continue - } - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(fullPath) - } - } - - return files -} - -function appendDiscoveredFiles(target: string[], source: readonly string[]): void { - // Why: long-lived transcript directories can exceed V8's argument limit if - // child file arrays are spread into push(). - for (const filePath of source) { - target.push(filePath) - } -} - -export async function listClaudeTranscriptFiles(): Promise { +export async function listClaudeTranscriptFiles( + budget = new UsageHistoryScanBudget() +): Promise { const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR] - const files = await Promise.all( - roots.map(async (root) => { - try { - return await walkJsonlFiles(root) - } catch { - return [] + const files: string[] = [] + for (const root of roots) { + try { + for (const filePath of await walkUsageHistoryJsonlFiles(root, budget)) { + files.push(filePath) } - }) - ) - return [...new Set(files.flat())].sort() + } catch (error) { + if (error instanceof UsageHistoryScanCapacityError) { + throw error + } + } + } + return [...new Set(files)].sort() } export async function getProcessedFileInfo(filePath: string): Promise { const fileStat = await stat(filePath) let lineCount = 0 - const lines = createInterface({ - input: createReadStream(filePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - for await (const _line of lines) { + for await (const _line of readUsageHistoryJsonlLines(filePath)) { lineCount++ } return { @@ -308,17 +289,17 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu return parsed ? stripClaudeSourceMetadata(parsed) : null } -export async function parseClaudeUsageFile(filePath: string): Promise { +export async function parseClaudeUsageFile( + filePath: string, + budget = new UsageHistoryScanBudget() +): Promise { const turns: ClaudeUsageParsedSourceTurn[] = [] const fallbackSessionId = basename(filePath, '.jsonl') - const lines = createInterface({ - input: createReadStream(filePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - for await (const line of lines) { + for await (const line of readUsageHistoryJsonlLines(filePath)) { const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId) if (parsed) { + claimClaudeTurn(budget, parsed) turns.push(parsed) } } @@ -326,7 +307,10 @@ export async function parseClaudeUsageFile(filePath: string): Promise { @@ -334,15 +318,12 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{ let lineCount = 0 const turns: ClaudeUsageParsedSourceTurn[] = [] const fallbackSessionId = basename(filePath, '.jsonl') - const lines = createInterface({ - input: createReadStream(filePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - for await (const line of lines) { + for await (const line of readUsageHistoryJsonlLines(filePath)) { lineCount++ const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId) if (parsed) { + claimClaudeTurn(budget, parsed) turns.push(parsed) } } @@ -358,6 +339,19 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{ } } +function claimClaudeTurn(budget: UsageHistoryScanBudget, turn: ClaudeUsageParsedSourceTurn): void { + budget.claimRecord( + getUsageHistoryRetainedBytes([ + turn.sessionId, + turn.timestamp, + turn.model, + turn.cwd, + turn.gitBranch, + turn.dedupeKey + ]) + ) +} + function localDayFromTimestamp(timestamp: string): string | null { const parsed = new Date(timestamp) if (Number.isNaN(parsed.getTime())) { @@ -492,6 +486,49 @@ function mergeClaudeDailyAggregates( } } +function claimClaudeUsageProjection( + budget: UsageHistoryScanBudget, + sessions: readonly ClaudeUsageSession[], + dailyAggregates: readonly ClaudeUsageDailyAggregate[] +): void { + for (const session of sessions) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + session.sessionId, + session.firstTimestamp, + session.lastTimestamp, + session.model, + session.lastCwd, + session.lastGitBranch, + session.primaryWorktreeId, + session.primaryRepoId + ]) + ) + for (const location of session.locationBreakdown) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + location.locationKey, + location.projectLabel, + location.repoId, + location.worktreeId + ]) + ) + } + } + for (const daily of dailyAggregates) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + daily.day, + daily.model, + daily.projectKey, + daily.projectLabel, + daily.repoId, + daily.worktreeId + ]) + ) + } +} + function finalizeClaudeSessions( sessionsById: Map ): ClaudeUsageSession[] { @@ -626,7 +663,14 @@ export async function scanClaudeUsageFiles( sessions: ClaudeUsageSession[] dailyAggregates: ClaudeUsageDailyAggregate[] }> { - const files = await listClaudeTranscriptFiles() + if (previousProcessedFiles.length > MAX_USAGE_HISTORY_FILES) { + throw new UsageHistoryScanCapacityError('files', MAX_USAGE_HISTORY_FILES) + } + const budget = new UsageHistoryScanBudget() + const files = await listClaudeTranscriptFiles(budget) + for (const previous of previousProcessedFiles) { + budget.claimPath(previous.path) + } const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file])) const worktreeLookup = await buildWorktreeLookup(worktrees) @@ -685,7 +729,12 @@ export async function scanClaudeUsageFiles( // turn for exactly one file; cached files keep the claims they persisted. const turnOwnerByDedupeKey = new Map() for (const [filePath, previous] of reusedByPath) { + for (const session of previous.sessions) { + budget.claimRecords(session.turnCount) + } + claimClaudeUsageProjection(budget, previous.sessions, previous.dailyAggregates) for (const dedupeKey of previous.ownedDedupeKeys) { + budget.claimOwnershipKey(dedupeKey) // First cached claim wins so conflicting projections stay deterministic. if (!turnOwnerByDedupeKey.has(dedupeKey)) { turnOwnerByDedupeKey.set(dedupeKey, filePath) @@ -698,7 +747,9 @@ export async function scanClaudeUsageFiles( const batch = pathsToParse.slice(index, index + FILE_SCAN_BATCH_SIZE) // Why: transcript scans run in Electron's main process. Small parallel // batches cut independent file I/O without letting Settings stay blocked. - const reads = await Promise.all(batch.map((filePath) => readClaudeUsageScanFile(filePath))) + const reads = await Promise.all( + batch.map((filePath) => readClaudeUsageScanFile(filePath, budget)) + ) for (const [batchIndex, filePath] of batch.entries()) { const { processedFile, turns } = reads[batchIndex] // Why: ownership claims must be sequential in sorted-path order so @@ -713,15 +764,20 @@ export async function scanClaudeUsageFiles( hasDeferredClaims = true continue } + if (owner === undefined) { + budget.claimOwnershipKey(turn.dedupeKey) + } turnOwnerByDedupeKey.set(turn.dedupeKey, filePath) ownedDedupeKeys.push(turn.dedupeKey) } ownedTurns.push(stripClaudeSourceMetadata(turn)) } const attributed = await attributeClaudeUsageTurns(ownedTurns, worktreeLookup) + const aggregates = aggregateClaudeUsage(attributed) + claimClaudeUsageProjection(budget, aggregates.sessions, aggregates.dailyAggregates) parsedByPath.set(filePath, { ...processedFile, - ...aggregateClaudeUsage(attributed), + ...aggregates, ownedDedupeKeys, hasDeferredClaims }) @@ -744,14 +800,17 @@ export async function scanClaudeUsageFiles( mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates) } + const sessions = finalizeClaudeSessions(sessionsById) + const dailyAggregates = [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + claimClaudeUsageProjection(budget, sessions, dailyAggregates) return { processedFiles, - sessions: finalizeClaudeSessions(sessionsById), - dailyAggregates: [...dailyByKey.values()].sort((left, right) => - left.day === right.day - ? left.projectLabel.localeCompare(right.projectLabel) - : left.day.localeCompare(right.day) - ) + sessions, + dailyAggregates } } diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 9c17fd21116..523d637ab9e 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -7,6 +7,7 @@ import { readFile, readlink, symlink, + truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -27,7 +28,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { CliInstaller } from './cli-installer' +import { CLI_LAUNCHER_INSPECTION_MAX_BYTES, CliInstaller } from './cli-installer' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' import { WindowsUserPathRegistryReader, @@ -706,6 +707,48 @@ describe('CliInstaller', () => { } ) + it.skipIf(process.platform === 'win32')( + 'preserves an oversized regular launcher even when its prefix resembles an Orca launcher', + async () => { + const fixture = await makeFixture() + const commandDir = join(fixture.root, 'bin') + const installPath = join(commandDir, 'orca') + const resourcesPath = await createPackagedMacLauncher(fixture.root) + await mkdir(commandDir, { recursive: true }) + await writeFile( + installPath, + [ + '#!/usr/bin/env bash', + 'ELECTRON_RUN_AS_NODE=1', + 'ORCA_NODE_OPTIONS=x', + 'NODE_REPL_EXTERNAL_MODULE=x', + `CLI='${join(fixture.root, 'out', 'cli', 'index.js')}'`, + '' + ].join('\n'), + 'utf8' + ) + await truncate(installPath, CLI_LAUNCHER_INSPECTION_MAX_BYTES + 1) + + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: true, + resourcesPath, + commandPathOverride: installPath, + processPathEnv: commandDir + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'conflict', + currentTarget: null + }) + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(lstat(installPath)).resolves.toHaveProperty( + 'size', + CLI_LAUNCHER_INSPECTION_MAX_BYTES + 1 + ) + } + ) + // Why: a dev build can temporarily own the public command on developer // machines; packaged Orca should treat that as stale, not a hard conflict. it.skipIf(process.platform === 'win32')( diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 18d2fcc8f18..c75bc09bf6d 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -2,21 +2,16 @@ import { app } from 'electron' import { execFile } from 'node:child_process' import { constants, existsSync } from 'node:fs' -import { - access, - lstat, - mkdir, - readFile, - readlink, - stat, - symlink, - unlink, - writeFile -} from 'node:fs/promises' +import { access, lstat, mkdir, readlink, stat, symlink, unlink, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import { promisify } from 'node:util' import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' +import { nodeFileContentsEqual } from '../../shared/node-file-content-equality' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' import { invalidateWindowsUserPathRegistryCache, @@ -32,6 +27,7 @@ const LINUX_COMMAND_NAME = 'orca-ide' const LEGACY_LINUX_COMMAND_NAME = 'orca' const DEV_LAUNCHER_DIR = ['cli', 'bin'] const WINDOWS_PATH_WRITE_TIMEOUT_MS = 5_000 +export const CLI_LAUNCHER_INSPECTION_MAX_BYTES = 64 * 1024 type CliInstallerOptions = { platform?: NodeJS.Platform @@ -500,19 +496,18 @@ export class CliInstaller { }) } - const currentContent = await readFile(commandPath, 'utf8') const expectedContent = buildAppImageCliWrapper(appImagePath) + const matches = await nodeFileContentsEqual(commandPath, expectedContent) return this.buildStatus({ commandPath, launcherPath: appImagePath, installMethod: 'wrapper', supported: true, - state: currentContent === expectedContent ? 'installed' : 'stale', + state: matches ? 'installed' : 'stale', currentTarget: appImagePath, - detail: - currentContent === expectedContent - ? `Registered at ${commandPath}.` - : `${commandPath} points to a different launcher.` + detail: matches + ? `Registered at ${commandPath}.` + : `${commandPath} points to a different launcher.` }) } catch (error) { if (isMissingError(error)) { @@ -538,8 +533,7 @@ export class CliInstaller { const stats = await lstat(commandPath) if (!stats.isSymbolicLink()) { if (stats.isFile()) { - const currentContent = await readFile(commandPath, 'utf8') - const managedTarget = extractManagedUnixLauncherTarget(currentContent) + const managedTarget = await readManagedUnixLauncherTarget(commandPath) if (managedTarget) { return this.buildStatus({ commandPath, @@ -694,19 +688,18 @@ export class CliInstaller { }) } - const currentContent = await readFile(commandPath, 'utf8') const expectedContent = buildWindowsForwarder(launcherPath) + const matches = await nodeFileContentsEqual(commandPath, expectedContent) return this.buildStatus({ commandPath, launcherPath, installMethod: 'wrapper', supported: true, - state: currentContent === expectedContent ? 'installed' : 'stale', + state: matches ? 'installed' : 'stale', currentTarget: launcherPath, - detail: - currentContent === expectedContent - ? `Registered at ${commandPath}.` - : `${commandPath} points to a different launcher.` + detail: matches + ? `Registered at ${commandPath}.` + : `${commandPath} points to a different launcher.` }) } catch (error) { if (isMissingError(error)) { @@ -1013,6 +1006,20 @@ function extractManagedUnixLauncherTarget(content: string): string | null { : null } +async function readManagedUnixLauncherTarget(filePath: string): Promise { + try { + const contents = ( + await readNodeFileWithinLimit(filePath, CLI_LAUNCHER_INSPECTION_MAX_BYTES) + ).buffer.toString('utf8') + return extractManagedUnixLauncherTarget(contents) + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + return null + } + throw error + } +} + function extractShellAssignment(content: string, name: string): string | null { const match = new RegExp(`^${name}=('([^']*)'|"([^"]*)"|([^\\n]+))$`, 'm').exec(content) if (!match) { diff --git a/src/main/cli/linux-bare-orca-dispatcher.test.ts b/src/main/cli/linux-bare-orca-dispatcher.test.ts index b2bb40e558b..b95b5c87a54 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.test.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -7,7 +7,10 @@ vi.mock('electron', () => ({ app: { isPackaged: true } })) -import { installLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher' +import { + LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES, + installLinuxBareOrcaDispatcher +} from './linux-bare-orca-dispatcher' const created: string[] = [] @@ -67,6 +70,45 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(second.state).toBe('installed') }) + it('recognizes an owned dispatcher at the exact inspection boundary', async () => { + const { homePath, resourcesPath } = await makeFixture() + const dispatcherPath = join(homePath, '.local', 'bin', 'orca') + const ownedPrefix = '#!/bin/sh\n# orca-serve-bare-orca-dispatcher\n' + await mkdir(join(homePath, '.local', 'bin'), { recursive: true }) + await writeFile( + dispatcherPath, + ownedPrefix + + ' '.repeat(LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES - Buffer.byteLength(ownedPrefix)), + 'utf8' + ) + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + expect(result.state).toBe('installed') + expect((await stat(dispatcherPath)).size).toBeLessThan(LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES) + }) + + it('preserves an oversized dispatcher even when its prefix contains the ownership marker', async () => { + const { homePath, resourcesPath } = await makeFixture() + const dispatcherPath = join(homePath, '.local', 'bin', 'orca') + await mkdir(join(homePath, '.local', 'bin'), { recursive: true }) + await writeFile(dispatcherPath, '#!/bin/sh\n# orca-serve-bare-orca-dispatcher\n', 'utf8') + await truncate(dispatcherPath, LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES + 1) + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + expect(result.state).toBe('skipped-foreign') + expect((await stat(dispatcherPath)).size).toBe(LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES + 1) + }) + it('quotes a resources path containing spaces so the exec line cannot be split', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-space-')) created.push(root) diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index 3dc8df2906c..3cb52914df9 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -1,13 +1,15 @@ import { existsSync } from 'node:fs' -import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import { chmod, mkdir, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' import { buildAppImageCliWrapper, quoteShell } from './appimage-cli-wrapper' import { getBundledLauncherPath } from './cli-installer' // Why: marks a dispatcher this function wrote so repeat serve starts overwrite // our own file idempotently but never clobber a user's own ~/.local/bin/orca. const DISPATCHER_MARKER = '# orca-serve-bare-orca-dispatcher' +export const LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES = 64 * 1024 export type LinuxBareOrcaDispatcherOptions = { /** Packaged app resources root; the bundled `orca-ide` launcher lives under it. */ @@ -106,7 +108,9 @@ function withMarker(script: string): string { async function isOwnedDispatcher(dispatcherPath: string): Promise { try { - return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER) + return ( + await readNodeFileWithinLimit(dispatcherPath, LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES) + ).buffer.includes(DISPATCHER_MARKER) } catch { return false } diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 13bd8088de3..d7d8be6c3fd 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -134,7 +134,7 @@ function createWslRunner( } return '' } - if (command.includes('cat ')) { + if (command.includes('head -c ')) { if (command.includes(commandPath)) { return files.get(commandPath) ?? '__ORCA_MISSING__' } @@ -267,7 +267,10 @@ describe('WslCliInstaller', () => { distro: 'Ubuntu', hostInstaller: { getStatus: async () => makeHostStatus('C:\\Orca\\orca.cmd') }, wslRunner: async (distro, command) => { - if (command.includes('cat /home/alice/.local/share/orca/orca-wsl-bridge.ps1')) { + if ( + command.includes('head -c ') && + command.includes('/home/alice/.local/share/orca/orca-wsl-bridge.ps1') + ) { return `${_internals.buildWslBridgeScript()}\n` } return wsl.runner(distro, command) @@ -828,9 +831,6 @@ describe('WslCliInstaller', () => { distro: 'Ubuntu', hostInstaller: { getStatus: async () => makeHostStatus('C:\\Orca\\orca.cmd') }, wslRunner: async (distro, command) => { - if (command.includes('cat /home/alice/.local/share/orca/orca-wsl-bridge.ps1')) { - return 'user bridge' - } if (command.includes('rm -f')) { throw new Error('__ORCA_CONFLICT__') } diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index f6a1684e19c..7153329c35f 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -24,6 +24,8 @@ const BRIDGE_MANAGED_MARKER = getWslBridgeMarker() const WSL_COMMAND_NAME = 'orca-ide' const LEGACY_WSL_COMMAND_NAME = 'orca' const WSL_COMMAND_TIMEOUT_MS = 10_000 +const WSL_BOUNDED_FILE_OUTPUT_PREFIX = '__ORCA_BOUNDED_FILE_BASE64__:' +export const WSL_CLI_INSPECTION_MAX_BYTES = 64 * 1024 function normalizeManagedScriptContent(content: string): string { return content.replace(/\n+$/u, '\n') @@ -383,6 +385,7 @@ export class WslCliInstaller { const output = await this.run( distro, [ + 'set -o pipefail', `if [ -L ${quoteShell(commandPath)} ]; then`, ' printf __ORCA_NOT_FILE__', `elif [ ! -e ${quoteShell(commandPath)} ]; then`, @@ -390,7 +393,8 @@ export class WslCliInstaller { `elif [ ! -f ${quoteShell(commandPath)} ]; then`, ' printf __ORCA_NOT_FILE__', 'else', - ` cat ${quoteShell(commandPath)}`, + ` printf ${quoteShell(WSL_BOUNDED_FILE_OUTPUT_PREFIX)}`, + ` head -c ${WSL_CLI_INSPECTION_MAX_BYTES + 1} -- ${quoteShell(commandPath)} | base64`, 'fi' ].join('\n') ) @@ -400,7 +404,7 @@ export class WslCliInstaller { if (output === '__ORCA_NOT_FILE__') { return 'not_file' } - return output + return parseBoundedWslFileOutput(output) } private buildStatus(args: { @@ -506,10 +510,24 @@ function buildEncodedWslBashCommand(command: string): string { return `set -o pipefail; printf %s ${quoteShell(encoded)} | base64 -d | bash` } +function parseBoundedWslFileOutput(output: string): string | 'not_file' { + if (!output.startsWith(WSL_BOUNDED_FILE_OUTPUT_PREFIX)) { + return output + } + const encoded = output.slice(WSL_BOUNDED_FILE_OUTPUT_PREFIX.length).replace(/\s/gu, '') + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded)) { + return 'not_file' + } + const decoded = Buffer.from(encoded, 'base64') + return decoded.length <= WSL_CLI_INSPECTION_MAX_BYTES ? decoded.toString('utf8') : 'not_file' +} + export const _internals = { buildEncodedWslBashCommand, buildWslBridgeScript, buildWslLauncher, getBridgePathFromCommandPath, - parseManagedLauncherTarget + parseManagedLauncherTarget, + parseBoundedWslFileOutput, + WSL_BOUNDED_FILE_OUTPUT_PREFIX } diff --git a/src/main/codex-accounts/codex-wsl-runtime-home-retention.ts b/src/main/codex-accounts/codex-wsl-runtime-home-retention.ts new file mode 100644 index 00000000000..8b15045577d --- /dev/null +++ b/src/main/codex-accounts/codex-wsl-runtime-home-retention.ts @@ -0,0 +1,140 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export type CodexWslRuntimeHomeRetentionBounds = { + maxEntries: number + maxKeyBytes: number + maxValueBytes: number + maxTotalBytes: number +} + +export const DEFAULT_CODEX_WSL_RUNTIME_HOME_RETENTION_BOUNDS: CodexWslRuntimeHomeRetentionBounds = { + maxEntries: 64, + maxKeyBytes: 1024, + maxValueBytes: 4 * 1024 * 1024, + maxTotalBytes: 8 * 1024 * 1024 +} + +type CodexWslRuntimeHomeEntry = { + runtimeHomePath?: string + lastWrittenAuthJson?: string | null + lastSyncedAccountId?: string | null + retainedBytes: number +} + +export class CodexWslRuntimeHomeRetention { + private readonly entries = new Map() + private retainedBytes = 0 + + constructor( + private readonly bounds: CodexWslRuntimeHomeRetentionBounds = DEFAULT_CODEX_WSL_RUNTIME_HOME_RETENTION_BOUNDS + ) { + for (const value of Object.values(bounds)) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError('Codex WSL runtime-home retention bounds must be positive integers') + } + } + } + + getRuntimeHomePath(distro: string): string | undefined { + return this.get(distro)?.runtimeHomePath + } + + hasLastSyncedAccountId(distro: string): boolean { + return this.get(distro)?.lastSyncedAccountId !== undefined + } + + getLastSyncedAccountId(distro: string): string | null | undefined { + return this.get(distro)?.lastSyncedAccountId + } + + getLastWrittenAuthJson(distro: string): string | null | undefined { + return this.get(distro)?.lastWrittenAuthJson + } + + setRuntimeHomePath(distro: string, runtimeHomePath: string): void { + this.update(distro, { runtimeHomePath }) + } + + setLastSyncedAccountId(distro: string, accountId: string | null): void { + this.update(distro, { lastSyncedAccountId: accountId }) + } + + setLastWrittenAuthJson(distro: string, authJson: string | null): void { + this.update(distro, { lastWrittenAuthJson: authJson }) + } + + evidence(): { entries: number; retainedBytes: number } { + return { entries: this.entries.size, retainedBytes: this.retainedBytes } + } + + private get(distro: string): CodexWslRuntimeHomeEntry | undefined { + const entry = this.entries.get(distro) + if (entry) { + this.entries.delete(distro) + this.entries.set(distro, entry) + } + return entry + } + + private update( + distro: string, + patch: Omit, 'retainedBytes'> + ): void { + const previous = this.entries.get(distro) + const next = { ...previous, ...patch, retainedBytes: 0 } + const retainedBytes = this.measureEntry(distro, next) + this.delete(distro) + if (retainedBytes === null || retainedBytes > this.bounds.maxTotalBytes) { + return + } + while ( + this.entries.size >= this.bounds.maxEntries || + this.retainedBytes + retainedBytes > this.bounds.maxTotalBytes + ) { + const oldest = this.entries.keys().next().value + if (oldest === undefined) { + return + } + this.delete(oldest) + } + next.retainedBytes = retainedBytes + this.entries.set(distro, next) + this.retainedBytes += retainedBytes + } + + private measureEntry(distro: string, entry: CodexWslRuntimeHomeEntry): number | null { + const keyBytes = measureUtf8ByteLength(distro, { + stopAfterBytes: this.bounds.maxKeyBytes + }) + if (keyBytes.exceededLimit) { + return null + } + let retainedBytes = keyBytes.byteLength + for (const value of [ + entry.runtimeHomePath, + entry.lastWrittenAuthJson, + entry.lastSyncedAccountId + ]) { + if (typeof value !== 'string') { + continue + } + const valueBytes = measureUtf8ByteLength(value, { + stopAfterBytes: this.bounds.maxValueBytes + }) + if (valueBytes.exceededLimit) { + return null + } + retainedBytes += valueBytes.byteLength + } + return retainedBytes + } + + private delete(distro: string): void { + const entry = this.entries.get(distro) + if (!entry) { + return + } + this.entries.delete(distro) + this.retainedBytes -= entry.retainedBytes + } +} diff --git a/src/main/codex-accounts/legacy-history-migration.test.ts b/src/main/codex-accounts/legacy-history-migration.test.ts new file mode 100644 index 00000000000..7b47c93d9ca --- /dev/null +++ b/src/main/codex-accounts/legacy-history-migration.test.ts @@ -0,0 +1,109 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + CODEX_LEGACY_HISTORY_READ_CHUNK_BYTES, + mergeCodexLegacyHistorySync +} from './legacy-history-migration' + +describe('Codex legacy history migration', () => { + let root = '' + let legacyHistoryPath = '' + let runtimeHistoryPath = '' + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-codex-legacy-history-')) + legacyHistoryPath = join(root, 'legacy-history.jsonl') + runtimeHistoryPath = join(root, 'runtime-history.jsonl') + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('preserves the legacy merge order and exact normalized output in limit', () => { + const longLegacyLine = `🦀${'x'.repeat(CODEX_LEGACY_HISTORY_READ_CHUNK_BYTES + 8)}` + writeFileSync(runtimeHistoryPath, 'first\n\nsecond\r\nlast') + writeFileSync(legacyHistoryPath, `second\r\nthird\nfirst\n\nthird\n${longLegacyLine}`) + + const result = mergeCodexLegacyHistorySync({ legacyHistoryPath, runtimeHistoryPath }) + + expect(result).toMatchObject({ kind: 'merged', addedLineCount: 2, outputLineCount: 5 }) + expect(readFileSync(runtimeHistoryPath, 'utf8')).toBe( + `first\nsecond\r\nlast\nthird\n${longLegacyLine}\n` + ) + }) + + it('leaves the runtime file byte-for-byte unchanged when output exceeds its limit', () => { + const originalRuntime = 'runtime\n' + writeFileSync(runtimeHistoryPath, originalRuntime) + writeFileSync(legacyHistoryPath, 'new-value\n') + + const result = mergeCodexLegacyHistorySync({ + legacyHistoryPath, + runtimeHistoryPath, + limits: { maxOutputBytes: Buffer.byteLength(originalRuntime) } + }) + + expect(result).toEqual({ + kind: 'skipped', + reason: 'output-bytes', + observed: Buffer.byteLength(originalRuntime) + Buffer.byteLength('new-value\n'), + limit: Buffer.byteLength(originalRuntime) + }) + expect(readFileSync(runtimeHistoryPath, 'utf8')).toBe(originalRuntime) + }) + + it('does not create partial output for an oversized source file or line', () => { + writeFileSync(legacyHistoryPath, '') + truncateSync(legacyHistoryPath, 4096) + + const fileResult = mergeCodexLegacyHistorySync({ + legacyHistoryPath, + runtimeHistoryPath, + limits: { maxFileBytes: 1024 } + }) + + expect(fileResult).toMatchObject({ kind: 'skipped', reason: 'file-bytes', limit: 1024 }) + expect(existsSync(runtimeHistoryPath)).toBe(false) + + writeFileSync(legacyHistoryPath, 'oversized\n') + const lineResult = mergeCodexLegacyHistorySync({ + legacyHistoryPath, + runtimeHistoryPath, + limits: { maxLineBytes: 4 } + }) + + expect(lineResult).toMatchObject({ kind: 'skipped', reason: 'line-bytes', limit: 4 }) + expect(existsSync(runtimeHistoryPath)).toBe(false) + }) + + it('bounds input records even when every record is empty or duplicated', () => { + writeFileSync(legacyHistoryPath, 'same\nsame\n\nsame\n') + + const result = mergeCodexLegacyHistorySync({ + legacyHistoryPath, + runtimeHistoryPath, + limits: { maxInputLines: 3 } + }) + + expect(result).toMatchObject({ + kind: 'skipped', + reason: 'input-lines', + observed: 4, + limit: 3 + }) + expect(existsSync(runtimeHistoryPath)).toBe(false) + }) + + it('leaves an existing empty-only runtime file untouched when no record survives', () => { + writeFileSync(runtimeHistoryPath, '\n\n') + writeFileSync(legacyHistoryPath, '\n') + + expect(mergeCodexLegacyHistorySync({ legacyHistoryPath, runtimeHistoryPath })).toEqual({ + kind: 'empty' + }) + expect(readFileSync(runtimeHistoryPath, 'utf8')).toBe('\n\n') + }) +}) diff --git a/src/main/codex-accounts/legacy-history-migration.ts b/src/main/codex-accounts/legacy-history-migration.ts new file mode 100644 index 00000000000..81dbd40c3ae --- /dev/null +++ b/src/main/codex-accounts/legacy-history-migration.ts @@ -0,0 +1,230 @@ +import { randomUUID } from 'node:crypto' +import { + closeSync, + existsSync, + fstatSync, + mkdirSync, + openSync, + readSync, + rmSync, + writeSync +} from 'node:fs' +import { dirname } from 'node:path' +import { renameFileWithWindowsRetry } from './fs-utils' + +export const CODEX_LEGACY_HISTORY_FILE_MAX_BYTES = 32 * 1024 * 1024 +export const CODEX_LEGACY_HISTORY_LINE_MAX_BYTES = 1024 * 1024 +export const CODEX_LEGACY_HISTORY_INPUT_MAX_LINES = 200_000 +export const CODEX_LEGACY_HISTORY_OUTPUT_MAX_BYTES = 32 * 1024 * 1024 +export const CODEX_LEGACY_HISTORY_READ_CHUNK_BYTES = 64 * 1024 + +export type CodexLegacyHistoryMigrationLimits = { + maxFileBytes: number + maxLineBytes: number + maxInputLines: number + maxOutputBytes: number +} + +export const DEFAULT_CODEX_LEGACY_HISTORY_MIGRATION_LIMITS: CodexLegacyHistoryMigrationLimits = { + maxFileBytes: CODEX_LEGACY_HISTORY_FILE_MAX_BYTES, + maxLineBytes: CODEX_LEGACY_HISTORY_LINE_MAX_BYTES, + maxInputLines: CODEX_LEGACY_HISTORY_INPUT_MAX_LINES, + maxOutputBytes: CODEX_LEGACY_HISTORY_OUTPUT_MAX_BYTES +} + +export type CodexLegacyHistorySkipReason = + | 'file-bytes' + | 'line-bytes' + | 'input-lines' + | 'output-bytes' + +export type CodexLegacyHistoryMigrationResult = + | { + kind: 'merged' + addedLineCount: number + outputBytes: number + outputLineCount: number + } + | { kind: 'empty' } + | { + kind: 'skipped' + reason: CodexLegacyHistorySkipReason + observed: number + limit: number + } + +class CodexLegacyHistoryCapacityError extends Error { + constructor( + readonly reason: CodexLegacyHistorySkipReason, + readonly observed: number, + readonly limit: number + ) { + super(`Codex legacy history exceeded ${reason} limit (${observed} > ${limit})`) + this.name = 'CodexLegacyHistoryCapacityError' + } +} + +export function mergeCodexLegacyHistorySync(options: { + legacyHistoryPath: string + runtimeHistoryPath: string + limits?: Partial +}): CodexLegacyHistoryMigrationResult { + const limits = resolveLimits(options.limits) + const temporaryPath = `${options.runtimeHistoryPath}.${process.pid}.${randomUUID()}.migration.tmp` + let outputDescriptor: number | null = null + + try { + mkdirSync(dirname(options.runtimeHistoryPath), { recursive: true }) + outputDescriptor = openSync(temporaryPath, 'wx') + const seenLines = new Set() + let inputLineCount = 0 + let outputBytes = 0 + let addedLineCount = 0 + + const retainLine = (line: string, fromLegacy: boolean): void => { + inputLineCount += 1 + assertWithinCapacity('input-lines', inputLineCount, limits.maxInputLines) + if (!line || seenLines.has(line)) { + return + } + + const encodedLine = Buffer.from(`${line}\n`, 'utf8') + assertWithinCapacity('output-bytes', outputBytes + encodedLine.length, limits.maxOutputBytes) + seenLines.add(line) + writeAll(outputDescriptor!, encodedLine) + outputBytes += encodedLine.length + if (fromLegacy) { + addedLineCount += 1 + } + } + + if (existsSync(options.runtimeHistoryPath)) { + readHistoryLinesSync(options.runtimeHistoryPath, limits, (line) => retainLine(line, false)) + } + readHistoryLinesSync(options.legacyHistoryPath, limits, (line) => retainLine(line, true)) + + closeSync(outputDescriptor) + outputDescriptor = null + if (seenLines.size === 0) { + rmSync(temporaryPath, { force: true }) + return { kind: 'empty' } + } + + renameFileWithWindowsRetry(temporaryPath, options.runtimeHistoryPath) + return { + kind: 'merged', + addedLineCount, + outputBytes, + outputLineCount: seenLines.size + } + } catch (error) { + closeIgnoringErrors(outputDescriptor) + rmSync(temporaryPath, { force: true }) + if (error instanceof CodexLegacyHistoryCapacityError) { + return { + kind: 'skipped', + reason: error.reason, + observed: error.observed, + limit: error.limit + } + } + throw error + } +} + +function readHistoryLinesSync( + filePath: string, + limits: CodexLegacyHistoryMigrationLimits, + onLine: (line: string) => void +): void { + const descriptor = openSync(filePath, 'r') + try { + const initialBytes = fstatSync(descriptor).size + assertWithinCapacity('file-bytes', initialBytes, limits.maxFileBytes) + const readBuffer = Buffer.allocUnsafe(CODEX_LEGACY_HISTORY_READ_CHUNK_BYTES) + let sourceBytes = 0 + let fragments: Buffer[] = [] + let fragmentBytes = 0 + + while (true) { + const bytesRead = readSync(descriptor, readBuffer, 0, readBuffer.length, null) + if (bytesRead === 0) { + break + } + sourceBytes += bytesRead + assertWithinCapacity('file-bytes', sourceBytes, limits.maxFileBytes) + + let offset = 0 + while (offset < bytesRead) { + const newlineIndex = readBuffer.indexOf(0x0a, offset) + const end = newlineIndex === -1 || newlineIndex >= bytesRead ? bytesRead : newlineIndex + const nextFragmentBytes = end - offset + assertWithinCapacity('line-bytes', fragmentBytes + nextFragmentBytes, limits.maxLineBytes) + if (nextFragmentBytes > 0) { + fragments.push(Buffer.from(readBuffer.subarray(offset, end))) + fragmentBytes += nextFragmentBytes + } + if (newlineIndex === -1 || newlineIndex >= bytesRead) { + break + } + onLine(decodeLine(fragments, fragmentBytes)) + fragments = [] + fragmentBytes = 0 + offset = newlineIndex + 1 + } + } + + if (fragmentBytes > 0) { + onLine(decodeLine(fragments, fragmentBytes)) + } + } finally { + closeSync(descriptor) + } +} + +function decodeLine(fragments: Buffer[], bytes: number): string { + if (fragments.length === 1) { + return fragments[0]!.toString('utf8') + } + return Buffer.concat(fragments, bytes).toString('utf8') +} + +function writeAll(descriptor: number, buffer: Buffer): void { + let offset = 0 + while (offset < buffer.length) { + offset += writeSync(descriptor, buffer, offset, buffer.length - offset) + } +} + +function assertWithinCapacity( + reason: CodexLegacyHistorySkipReason, + observed: number, + limit: number +): void { + if (observed > limit) { + throw new CodexLegacyHistoryCapacityError(reason, observed, limit) + } +} + +function resolveLimits( + overrides: Partial | undefined +): CodexLegacyHistoryMigrationLimits { + const limits = { ...DEFAULT_CODEX_LEGACY_HISTORY_MIGRATION_LIMITS, ...overrides } + for (const [name, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + } + return limits +} + +function closeIgnoringErrors(descriptor: number | null): void { + if (descriptor === null) { + return + } + try { + closeSync(descriptor) + } catch { + // The original migration error is more actionable than cleanup failure. + } +} diff --git a/src/main/codex-accounts/legacy-managed-state-migration.test.ts b/src/main/codex-accounts/legacy-managed-state-migration.test.ts new file mode 100644 index 00000000000..e43b7d8f6d9 --- /dev/null +++ b/src/main/codex-accounts/legacy-managed-state-migration.test.ts @@ -0,0 +1,163 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { migrateLegacyManagedCodexStateSync } from './legacy-managed-state-migration' + +describe('Codex legacy managed-state migration', () => { + let root = '' + let managedAccountsRoot = '' + let metadataDir = '' + let runtimeHomePath = '' + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-codex-managed-state-')) + managedAccountsRoot = join(root, 'codex-accounts') + metadataDir = join(root, 'metadata') + runtimeHomePath = join(root, 'runtime') + mkdirSync(managedAccountsRoot) + mkdirSync(metadataDir) + mkdirSync(runtimeHomePath) + }) + + afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) + }) + + function createManagedHome(accountId: string): string { + const homePath = join(managedAccountsRoot, accountId, 'home') + mkdirSync(homePath, { recursive: true }) + writeFileSync(join(homePath, '.orca-managed-home'), '') + return homePath + } + + function migrate( + limits?: Parameters[0]['limits'] + ): ReturnType { + return migrateLegacyManagedCodexStateSync({ + managedAccountsRoot, + metadataDir, + runtimeHomePath, + limits + }) + } + + it('preserves ordinary history, session, diagnostic, and one-shot marker behavior', () => { + const homePath = createManagedHome('account-1') + writeFileSync(join(runtimeHomePath, 'history.jsonl'), '{"id":"shared"}\n') + writeFileSync(join(homePath, 'history.jsonl'), '{"id":"shared"}\n{"id":"managed"}\n') + mkdirSync(join(homePath, 'sessions')) + mkdirSync(join(runtimeHomePath, 'sessions')) + writeFileSync(join(homePath, 'sessions', 'conflict.jsonl'), 'legacy\n') + writeFileSync(join(runtimeHomePath, 'sessions', 'conflict.jsonl'), 'runtime\n') + + const summary = migrate() + + expect(summary).toMatchObject({ + diagnosticRecordsOmitted: 0, + diagnosticRecordsWritten: 1, + historySkippedHomeCount: 0, + managedHomeDiscoverySkipped: false, + migratedHomeCount: 1, + sessionSkippedHomeCount: 0 + }) + expect(readFileSync(join(runtimeHomePath, 'history.jsonl'), 'utf8')).toBe( + '{"id":"shared"}\n{"id":"managed"}\n' + ) + expect( + readFileSync( + join(runtimeHomePath, 'sessions', 'conflict.orca-legacy-account-1.jsonl'), + 'utf8' + ) + ).toBe('legacy\n') + const diagnostic = readFileSync(join(metadataDir, 'migration-diagnostics.jsonl'), 'utf8') + expect(diagnostic).toContain('"type":"session-conflict"') + + const marker = JSON.parse(readFileSync(join(metadataDir, 'migration-v1.json'), 'utf8')) + expect(Object.keys(marker).sort()).toEqual(['completedAt', 'migratedHomeCount']) + expect(marker.migratedHomeCount).toBe(1) + expect(migrate()).toBeNull() + }) + + it('skips all homes deterministically when managed-home discovery exceeds its cap', () => { + const firstHome = createManagedHome('account-1') + createManagedHome('account-2') + writeFileSync(join(firstHome, 'history.jsonl'), '{"id":"must-not-import"}\n') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const summary = migrate({ maxAccountEntries: 1 }) + + expect(summary).toMatchObject({ + managedHomeDiscoverySkipped: true, + migratedHomeCount: 0 + }) + expect(existsSync(join(runtimeHomePath, 'history.jsonl'))).toBe(false) + const marker = JSON.parse(readFileSync(join(metadataDir, 'migration-v1.json'), 'utf8')) + expect(marker).toMatchObject({ + managedHomeDiscoverySkipped: true, + migratedHomeCount: 0 + }) + expect(readFileSync(join(metadataDir, 'migration-diagnostics.jsonl'), 'utf8')).toContain( + '"type":"managed-home-discovery-skipped"' + ) + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + + it('records history and session capacity skips without partial output', () => { + const homePath = createManagedHome('account-1') + writeFileSync(join(homePath, 'history.jsonl'), 'oversized\n') + mkdirSync(join(homePath, 'sessions')) + writeFileSync(join(homePath, 'sessions', 'a.jsonl'), 'a') + writeFileSync(join(homePath, 'sessions', 'b.jsonl'), 'b') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const summary = migrate({ + history: { maxFileBytes: 4 }, + sessions: { maxEntries: 1 } + }) + + expect(summary).toMatchObject({ + historySkippedHomeCount: 1, + sessionSkippedHomeCount: 1 + }) + expect(existsSync(join(runtimeHomePath, 'history.jsonl'))).toBe(false) + expect(existsSync(join(runtimeHomePath, 'sessions'))).toBe(false) + const marker = JSON.parse(readFileSync(join(metadataDir, 'migration-v1.json'), 'utf8')) + expect(marker).toMatchObject({ + historySkippedHomeCount: 1, + sessionSkippedHomeCount: 1 + }) + const diagnostics = readFileSync(join(metadataDir, 'migration-diagnostics.jsonl'), 'utf8') + expect(diagnostics).toContain('"type":"history-skipped"') + expect(diagnostics).toContain('"type":"sessions-skipped"') + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + + it('caps diagnostic output while still preserving every conflicting session', () => { + const homePath = createManagedHome('account-1') + mkdirSync(join(homePath, 'sessions')) + mkdirSync(join(runtimeHomePath, 'sessions')) + for (const name of ['a.jsonl', 'b.jsonl']) { + writeFileSync(join(homePath, 'sessions', name), `legacy-${name}`) + writeFileSync(join(runtimeHomePath, 'sessions', name), `runtime-${name}`) + } + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const summary = migrate({ maxDiagnosticRecords: 1 }) + + expect(summary).toMatchObject({ + diagnosticRecordsOmitted: 1, + diagnosticRecordsWritten: 1 + }) + expect(existsSync(join(runtimeHomePath, 'sessions', 'a.orca-legacy-account-1.jsonl'))).toBe( + true + ) + expect(existsSync(join(runtimeHomePath, 'sessions', 'b.orca-legacy-account-1.jsonl'))).toBe( + true + ) + const marker = JSON.parse(readFileSync(join(metadataDir, 'migration-v1.json'), 'utf8')) + expect(marker.diagnosticRecordsOmitted).toBe(1) + expect(warnSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/codex-accounts/legacy-managed-state-migration.ts b/src/main/codex-accounts/legacy-managed-state-migration.ts new file mode 100644 index 00000000000..9da9cbf10e5 --- /dev/null +++ b/src/main/codex-accounts/legacy-managed-state-migration.ts @@ -0,0 +1,317 @@ +import { appendFileSync, existsSync, opendirSync } from 'node:fs' +import { join } from 'node:path' +import { writeFileAtomically } from './fs-utils' +import { + mergeCodexLegacyHistorySync, + type CodexLegacyHistoryMigrationLimits +} from './legacy-history-migration' +import { + migrateCodexLegacySessionsSync, + type CodexLegacySessionMigrationLimits +} from './legacy-session-migration' + +export const CODEX_LEGACY_MANAGED_ACCOUNT_MAX_ENTRIES = 4096 +export const CODEX_LEGACY_MANAGED_HOME_MAX_COUNT = 256 +export const CODEX_LEGACY_MIGRATION_DIAGNOSTIC_MAX_RECORDS = 2048 +export const CODEX_LEGACY_MIGRATION_DIAGNOSTIC_MAX_BYTES = 1024 * 1024 + +type LegacyManagedHome = { + accountId: string + homePath: string +} + +type ManagedHomeDiscoveryResult = + | { kind: 'complete'; homes: LegacyManagedHome[] } + | { + kind: 'skipped' + reason: 'account-entries' | 'managed-homes' + observed: number + limit: number + } + +type MigrationDiagnostic = Record + +export type CodexLegacyManagedStateMigrationLimits = { + maxAccountEntries: number + maxManagedHomes: number + maxDiagnosticRecords: number + maxDiagnosticBytes: number + history?: Partial + sessions?: Partial +} + +export type CodexLegacyManagedStateMigrationSummary = { + diagnosticRecordsOmitted: number + diagnosticRecordsWritten: number + historySkippedHomeCount: number + managedHomeDiscoverySkipped: boolean + migratedHomeCount: number + sessionSkippedHomeCount: number +} + +const DEFAULT_LIMITS: CodexLegacyManagedStateMigrationLimits = { + maxAccountEntries: CODEX_LEGACY_MANAGED_ACCOUNT_MAX_ENTRIES, + maxManagedHomes: CODEX_LEGACY_MANAGED_HOME_MAX_COUNT, + maxDiagnosticRecords: CODEX_LEGACY_MIGRATION_DIAGNOSTIC_MAX_RECORDS, + maxDiagnosticBytes: CODEX_LEGACY_MIGRATION_DIAGNOSTIC_MAX_BYTES +} + +export function migrateLegacyManagedCodexStateSync(options: { + managedAccountsRoot: string + metadataDir: string + runtimeHomePath: string + limits?: Partial +}): CodexLegacyManagedStateMigrationSummary | null { + const markerPath = join(options.metadataDir, 'migration-v1.json') + if (existsSync(markerPath)) { + return null + } + + const limits = resolveLimits(options.limits) + const diagnostics = new MigrationDiagnosticWriter( + join(options.metadataDir, 'migration-diagnostics.jsonl'), + limits.maxDiagnosticRecords, + limits.maxDiagnosticBytes + ) + const discovery = discoverLegacyManagedHomesSync(options.managedAccountsRoot, limits) + let historySkippedHomeCount = 0 + let sessionSkippedHomeCount = 0 + + if (discovery.kind === 'skipped') { + diagnostics.append({ + type: 'managed-home-discovery-skipped', + reason: discovery.reason, + observed: discovery.observed, + limit: discovery.limit + }) + } else { + for (const home of discovery.homes) { + const legacyHistoryPath = join(home.homePath, 'history.jsonl') + if (existsSync(legacyHistoryPath)) { + const historyResult = mergeCodexLegacyHistorySync({ + legacyHistoryPath, + runtimeHistoryPath: join(options.runtimeHomePath, 'history.jsonl'), + limits: limits.history + }) + if (historyResult.kind === 'skipped') { + historySkippedHomeCount += 1 + diagnostics.append({ + type: 'history-skipped', + accountId: home.accountId, + legacyHistoryPath, + reason: historyResult.reason, + observed: historyResult.observed, + limit: historyResult.limit + }) + } + } + + const legacySessionsRoot = join(home.homePath, 'sessions') + if (!existsSync(legacySessionsRoot)) { + continue + } + const sessionResult = migrateCodexLegacySessionsSync({ + accountId: home.accountId, + legacySessionsRoot, + runtimeSessionsRoot: join(options.runtimeHomePath, 'sessions'), + limits: limits.sessions, + onConflict: ({ runtimeFilePath, preservedPath }) => { + diagnostics.append({ + type: 'session-conflict', + accountId: home.accountId, + runtimeFilePath, + preservedPath + }) + } + }) + if (sessionResult.kind === 'skipped') { + sessionSkippedHomeCount += 1 + diagnostics.append({ + type: 'sessions-skipped', + accountId: home.accountId, + legacySessionsRoot, + reason: sessionResult.reason, + observed: sessionResult.observed, + limit: sessionResult.limit + }) + } + } + } + + const summary: CodexLegacyManagedStateMigrationSummary = { + diagnosticRecordsOmitted: diagnostics.omittedCount, + diagnosticRecordsWritten: diagnostics.writtenCount, + historySkippedHomeCount, + managedHomeDiscoverySkipped: discovery.kind === 'skipped', + migratedHomeCount: discovery.kind === 'complete' ? discovery.homes.length : 0, + sessionSkippedHomeCount + } + writeMigrationMarker(markerPath, summary) + warnAboutCapacitySkips(summary, markerPath) + return summary +} + +function discoverLegacyManagedHomesSync( + managedAccountsRoot: string, + limits: CodexLegacyManagedStateMigrationLimits +): ManagedHomeDiscoveryResult { + if (!existsSync(managedAccountsRoot)) { + return { kind: 'complete', homes: [] } + } + + const homes: LegacyManagedHome[] = [] + let entryCount = 0 + const directory = opendirSync(managedAccountsRoot) + try { + while (true) { + const entry = directory.readSync() + if (entry === null) { + break + } + entryCount += 1 + if (entryCount > limits.maxAccountEntries) { + return { + kind: 'skipped', + reason: 'account-entries', + observed: entryCount, + limit: limits.maxAccountEntries + } + } + if (!entry.isDirectory()) { + continue + } + const homePath = join(managedAccountsRoot, entry.name, 'home') + if (!existsSync(join(homePath, '.orca-managed-home'))) { + continue + } + homes.push({ accountId: entry.name, homePath }) + if (homes.length > limits.maxManagedHomes) { + return { + kind: 'skipped', + reason: 'managed-homes', + observed: homes.length, + limit: limits.maxManagedHomes + } + } + } + } finally { + closeDirectoryIgnoringAlreadyClosed(directory) + } + return { + kind: 'complete', + homes: homes.sort(compareManagedHomePaths) + } +} + +function compareManagedHomePaths(left: LegacyManagedHome, right: LegacyManagedHome): number { + if (left.homePath < right.homePath) { + return -1 + } + return left.homePath > right.homePath ? 1 : 0 +} + +function writeMigrationMarker( + markerPath: string, + summary: CodexLegacyManagedStateMigrationSummary +): void { + const capacityDetails = { + ...(summary.managedHomeDiscoverySkipped ? { managedHomeDiscoverySkipped: true } : {}), + ...(summary.historySkippedHomeCount > 0 + ? { historySkippedHomeCount: summary.historySkippedHomeCount } + : {}), + ...(summary.sessionSkippedHomeCount > 0 + ? { sessionSkippedHomeCount: summary.sessionSkippedHomeCount } + : {}), + ...(summary.diagnosticRecordsOmitted > 0 + ? { diagnosticRecordsOmitted: summary.diagnosticRecordsOmitted } + : {}) + } + writeFileAtomically( + markerPath, + `${JSON.stringify({ + completedAt: Date.now(), + migratedHomeCount: summary.migratedHomeCount, + ...capacityDetails + })}\n` + ) +} + +function warnAboutCapacitySkips( + summary: CodexLegacyManagedStateMigrationSummary, + markerPath: string +): void { + const skippedCount = + Number(summary.managedHomeDiscoverySkipped) + + summary.historySkippedHomeCount + + summary.sessionSkippedHomeCount + if (skippedCount === 0 && summary.diagnosticRecordsOmitted === 0) { + return + } + console.warn('[codex-runtime-home] Legacy state migration completed with bounded skips:', { + skippedCount, + diagnosticRecordsOmitted: summary.diagnosticRecordsOmitted, + markerPath + }) +} + +class MigrationDiagnosticWriter { + private bytesWritten = 0 + private failed = false + omittedCount = 0 + writtenCount = 0 + + constructor( + private readonly path: string, + private readonly maxRecords: number, + private readonly maxBytes: number + ) {} + + append(record: MigrationDiagnostic): void { + const line = `${JSON.stringify(record)}\n` + const lineBytes = Buffer.byteLength(line) + if ( + this.failed || + this.writtenCount >= this.maxRecords || + lineBytes > this.maxBytes - this.bytesWritten + ) { + this.omittedCount += 1 + return + } + try { + appendFileSync(this.path, line, { encoding: 'utf8' }) + this.writtenCount += 1 + this.bytesWritten += lineBytes + } catch (error) { + this.failed = true + this.omittedCount += 1 + console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) + } + } +} + +function resolveLimits( + overrides: Partial | undefined +): CodexLegacyManagedStateMigrationLimits { + const limits = { ...DEFAULT_LIMITS, ...overrides } + for (const [name, value] of Object.entries({ + maxAccountEntries: limits.maxAccountEntries, + maxManagedHomes: limits.maxManagedHomes, + maxDiagnosticRecords: limits.maxDiagnosticRecords, + maxDiagnosticBytes: limits.maxDiagnosticBytes + })) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + } + return limits +} + +function closeDirectoryIgnoringAlreadyClosed(directory: ReturnType): void { + try { + directory.closeSync() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') { + throw error + } + } +} diff --git a/src/main/codex-accounts/legacy-session-migration.test.ts b/src/main/codex-accounts/legacy-session-migration.test.ts new file mode 100644 index 00000000000..7ba50dd7302 --- /dev/null +++ b/src/main/codex-accounts/legacy-session-migration.test.ts @@ -0,0 +1,174 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + truncateSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + CODEX_LEGACY_SESSION_COMPARE_CHUNK_BYTES, + codexLegacySessionFilesEqualSync, + migrateCodexLegacySessionsSync +} from './legacy-session-migration' + +describe('Codex legacy session migration', () => { + let root = '' + let legacySessionsRoot = '' + let runtimeSessionsRoot = '' + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-codex-legacy-sessions-')) + legacySessionsRoot = join(root, 'legacy') + runtimeSessionsRoot = join(root, 'runtime') + mkdirSync(legacySessionsRoot) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('preserves creation of the runtime sessions directory for an empty legacy tree', () => { + const result = migrateCodexLegacySessionsSync({ + accountId: 'account-1', + legacySessionsRoot, + runtimeSessionsRoot + }) + + expect(result).toMatchObject({ + kind: 'migrated', + copiedFileCount: 0, + discoveredEntryCount: 0, + discoveredFileCount: 0 + }) + expect(existsSync(runtimeSessionsRoot)).toBe(true) + }) + + it('copies and conflict-preserves ordinary files in the historical sorted order', () => { + mkdirSync(join(legacySessionsRoot, 'nested')) + mkdirSync(join(runtimeSessionsRoot, 'nested'), { recursive: true }) + writeFileSync(join(legacySessionsRoot, 'z-copy.jsonl'), 'copy\n') + writeFileSync(join(legacySessionsRoot, 'a-conflict.jsonl'), 'legacy\n') + writeFileSync(join(runtimeSessionsRoot, 'a-conflict.jsonl'), 'runtime\n') + writeFileSync(join(legacySessionsRoot, 'nested', 'same.jsonl'), 'same\n') + writeFileSync(join(runtimeSessionsRoot, 'nested', 'same.jsonl'), 'same\n') + const conflicts: string[] = [] + + const result = migrateCodexLegacySessionsSync({ + accountId: 'account-1', + legacySessionsRoot, + runtimeSessionsRoot, + onConflict: ({ runtimeFilePath }) => conflicts.push(runtimeFilePath) + }) + + expect(result).toMatchObject({ + kind: 'migrated', + conflictCount: 1, + copiedFileCount: 2, + discoveredEntryCount: 4, + discoveredFileCount: 3 + }) + expect(conflicts).toEqual([join(runtimeSessionsRoot, 'a-conflict.jsonl')]) + expect(readFileSync(join(runtimeSessionsRoot, 'z-copy.jsonl'), 'utf8')).toBe('copy\n') + expect( + readFileSync(join(runtimeSessionsRoot, 'a-conflict.orca-legacy-account-1.jsonl'), 'utf8') + ).toBe('legacy\n') + expect(readFileSync(join(runtimeSessionsRoot, 'a-conflict.jsonl'), 'utf8')).toBe('runtime\n') + }) + + it('compares sparse multi-megabyte collisions with fixed-size buffers', () => { + mkdirSync(runtimeSessionsRoot) + const legacyPath = join(legacySessionsRoot, 'large.jsonl') + const runtimePath = join(runtimeSessionsRoot, 'large.jsonl') + const sparseBytes = CODEX_LEGACY_SESSION_COMPARE_CHUNK_BYTES * 128 + for (const path of [legacyPath, runtimePath]) { + writeFileSync(path, 'same-prefix') + truncateSync(path, sparseBytes) + } + + expect(codexLegacySessionFilesEqualSync(legacyPath, runtimePath)).toBe(true) + const result = migrateCodexLegacySessionsSync({ + accountId: 'account-1', + legacySessionsRoot, + runtimeSessionsRoot + }) + + expect(result).toMatchObject({ kind: 'migrated', conflictCount: 0, copiedFileCount: 0 }) + expect(existsSync(join(runtimeSessionsRoot, 'large.orca-legacy-account-1.jsonl'))).toBe(false) + }) + + it('preflights the entry cap before copying any file', () => { + writeFileSync(join(legacySessionsRoot, 'a.jsonl'), 'a') + writeFileSync(join(legacySessionsRoot, 'b.jsonl'), 'b') + + const result = migrateCodexLegacySessionsSync({ + accountId: 'account-1', + legacySessionsRoot, + runtimeSessionsRoot, + limits: { maxEntries: 1 } + }) + + expect(result).toMatchObject({ + kind: 'skipped', + reason: 'entries', + observed: 2, + limit: 1 + }) + expect(existsSync(runtimeSessionsRoot)).toBe(false) + }) + + it('preflights depth and aggregate file bytes before copying', () => { + mkdirSync(join(legacySessionsRoot, 'one', 'two'), { recursive: true }) + writeFileSync(join(legacySessionsRoot, 'one', 'two', 'deep.jsonl'), 'deep') + + const depthResult = migrateCodexLegacySessionsSync({ + accountId: 'account-1', + legacySessionsRoot, + runtimeSessionsRoot, + limits: { maxDepth: 1 } + }) + + expect(depthResult).toMatchObject({ kind: 'skipped', reason: 'depth', observed: 2, limit: 1 }) + expect(existsSync(runtimeSessionsRoot)).toBe(false) + + rmSync(join(legacySessionsRoot, 'one'), { recursive: true }) + writeFileSync(join(legacySessionsRoot, 'a.jsonl'), '1234') + writeFileSync(join(legacySessionsRoot, 'b.jsonl'), '5678') + const byteResult = migrateCodexLegacySessionsSync({ + accountId: 'account-1', + legacySessionsRoot, + runtimeSessionsRoot, + limits: { maxTotalFileBytes: 7 } + }) + + expect(byteResult).toMatchObject({ + kind: 'skipped', + reason: 'total-file-bytes', + observed: 8, + limit: 7 + }) + expect(existsSync(runtimeSessionsRoot)).toBe(false) + }) + + it('bounds retained path text independently of entry count', () => { + writeFileSync(join(legacySessionsRoot, 'long-name.jsonl'), 'content') + + const result = migrateCodexLegacySessionsSync({ + accountId: 'account-1', + legacySessionsRoot, + runtimeSessionsRoot, + limits: { maxPathCodeUnits: legacySessionsRoot.length } + }) + + expect(result).toMatchObject({ + kind: 'skipped', + reason: 'path-code-units', + limit: legacySessionsRoot.length + }) + expect(existsSync(runtimeSessionsRoot)).toBe(false) + }) +}) diff --git a/src/main/codex-accounts/legacy-session-migration.ts b/src/main/codex-accounts/legacy-session-migration.ts new file mode 100644 index 00000000000..b36b84f16c6 --- /dev/null +++ b/src/main/codex-accounts/legacy-session-migration.ts @@ -0,0 +1,305 @@ +import { randomUUID } from 'node:crypto' +import { + closeSync, + existsSync, + fstatSync, + mkdirSync, + openSync, + opendirSync, + readSync, + rmSync, + statSync +} from 'node:fs' +import { dirname, extname, join, relative } from 'node:path' +import { copyFileWithWindowsRetry, renameFileWithWindowsRetry } from './fs-utils' + +export const CODEX_LEGACY_SESSION_MAX_DEPTH = 32 +export const CODEX_LEGACY_SESSION_MAX_ENTRIES = 50_000 +export const CODEX_LEGACY_SESSION_MAX_TOTAL_FILE_BYTES = 4 * 1024 * 1024 * 1024 +export const CODEX_LEGACY_SESSION_MAX_PATH_CODE_UNITS = 4 * 1024 * 1024 +export const CODEX_LEGACY_SESSION_COMPARE_CHUNK_BYTES = 64 * 1024 + +export type CodexLegacySessionMigrationLimits = { + maxDepth: number + maxEntries: number + maxTotalFileBytes: number + maxPathCodeUnits: number +} + +export const DEFAULT_CODEX_LEGACY_SESSION_MIGRATION_LIMITS: CodexLegacySessionMigrationLimits = { + maxDepth: CODEX_LEGACY_SESSION_MAX_DEPTH, + maxEntries: CODEX_LEGACY_SESSION_MAX_ENTRIES, + maxTotalFileBytes: CODEX_LEGACY_SESSION_MAX_TOTAL_FILE_BYTES, + maxPathCodeUnits: CODEX_LEGACY_SESSION_MAX_PATH_CODE_UNITS +} + +export type CodexLegacySessionSkipReason = + | 'depth' + | 'entries' + | 'total-file-bytes' + | 'path-code-units' + +export type CodexLegacySessionMigrationResult = + | { + kind: 'migrated' + conflictCount: number + copiedFileCount: number + discoveredEntryCount: number + discoveredFileBytes: number + discoveredFileCount: number + } + | { + kind: 'skipped' + reason: CodexLegacySessionSkipReason + observed: number + limit: number + visitedEntryCount: number + } + +type TraversalResult = + | { + kind: 'complete' + entryCount: number + fileBytes: number + filePaths: string[] + } + | { + kind: 'skipped' + reason: CodexLegacySessionSkipReason + observed: number + limit: number + visitedEntryCount: number + } + +class CodexLegacySessionCapacityError extends Error { + constructor( + readonly reason: CodexLegacySessionSkipReason, + readonly observed: number, + readonly limit: number, + readonly visitedEntryCount: number + ) { + super(`Codex legacy sessions exceeded ${reason} limit (${observed} > ${limit})`) + this.name = 'CodexLegacySessionCapacityError' + } +} + +export function migrateCodexLegacySessionsSync(options: { + accountId: string + legacySessionsRoot: string + runtimeSessionsRoot: string + limits?: Partial + onConflict?: (conflict: { runtimeFilePath: string; preservedPath: string }) => void +}): CodexLegacySessionMigrationResult { + const limits = resolveLimits(options.limits) + const traversal = collectLegacySessionFilesSync(options.legacySessionsRoot, limits) + if (traversal.kind === 'skipped') { + return traversal + } + + mkdirSync(options.runtimeSessionsRoot, { recursive: true }) + let conflictCount = 0 + let copiedFileCount = 0 + for (const legacyFilePath of traversal.filePaths) { + const relativePath = relative(options.legacySessionsRoot, legacyFilePath) + const runtimeFilePath = join(options.runtimeSessionsRoot, relativePath) + mkdirSync(dirname(runtimeFilePath), { recursive: true }) + if (!existsSync(runtimeFilePath)) { + copyFileAtomicallySync(legacyFilePath, runtimeFilePath) + copiedFileCount += 1 + continue + } + if (codexLegacySessionFilesEqualSync(legacyFilePath, runtimeFilePath)) { + continue + } + + const preservedPath = getPreservedLegacySessionPath(runtimeFilePath, options.accountId) + copyFileAtomicallySync(legacyFilePath, preservedPath) + conflictCount += 1 + copiedFileCount += 1 + options.onConflict?.({ runtimeFilePath, preservedPath }) + } + + return { + kind: 'migrated', + conflictCount, + copiedFileCount, + discoveredEntryCount: traversal.entryCount, + discoveredFileBytes: traversal.fileBytes, + discoveredFileCount: traversal.filePaths.length + } +} + +function collectLegacySessionFilesSync( + rootPath: string, + limits: CodexLegacySessionMigrationLimits +): TraversalResult { + let entryCount = 0 + let fileBytes = 0 + let pathCodeUnits = rootPath.length + const filePaths: string[] = [] + const rootStats = statSync(rootPath) + if (rootStats.isFile()) { + assertWithinCapacity('total-file-bytes', rootStats.size, limits.maxTotalFileBytes, 0) + assertWithinCapacity('path-code-units', pathCodeUnits, limits.maxPathCodeUnits, 0) + return { kind: 'complete', entryCount: 0, fileBytes: rootStats.size, filePaths: [rootPath] } + } + + const pendingDirectories = [{ depth: 0, path: rootPath }] + try { + while (pendingDirectories.length > 0) { + const current = pendingDirectories.pop()! + const directory = opendirSync(current.path) + try { + while (true) { + const entry = directory.readSync() + if (entry === null) { + break + } + entryCount += 1 + assertWithinCapacity('entries', entryCount, limits.maxEntries, entryCount) + const childPath = join(current.path, entry.name) + pathCodeUnits += childPath.length + assertWithinCapacity( + 'path-code-units', + pathCodeUnits, + limits.maxPathCodeUnits, + entryCount + ) + + if (entry.isDirectory()) { + const childDepth = current.depth + 1 + assertWithinCapacity('depth', childDepth, limits.maxDepth, entryCount) + pendingDirectories.push({ depth: childDepth, path: childPath }) + continue + } + if (!entry.isFile()) { + continue + } + + const size = statSync(childPath).size + fileBytes += size + assertWithinCapacity('total-file-bytes', fileBytes, limits.maxTotalFileBytes, entryCount) + filePaths.push(childPath) + } + } finally { + closeDirectoryIgnoringAlreadyClosed(directory) + } + } + } catch (error) { + if (error instanceof CodexLegacySessionCapacityError) { + return { + kind: 'skipped', + reason: error.reason, + observed: error.observed, + limit: error.limit, + visitedEntryCount: error.visitedEntryCount + } + } + throw error + } + + return { kind: 'complete', entryCount, fileBytes, filePaths: filePaths.sort() } +} + +export function codexLegacySessionFilesEqualSync(leftPath: string, rightPath: string): boolean { + let leftDescriptor: number | null = null + let rightDescriptor: number | null = null + try { + leftDescriptor = openSync(leftPath, 'r') + rightDescriptor = openSync(rightPath, 'r') + if (fstatSync(leftDescriptor).size !== fstatSync(rightDescriptor).size) { + return false + } + + const leftBuffer = Buffer.allocUnsafe(CODEX_LEGACY_SESSION_COMPARE_CHUNK_BYTES) + const rightBuffer = Buffer.allocUnsafe(CODEX_LEGACY_SESSION_COMPARE_CHUNK_BYTES) + while (true) { + const leftBytes = readFullChunkSync(leftDescriptor, leftBuffer) + const rightBytes = readFullChunkSync(rightDescriptor, rightBuffer) + if (leftBytes !== rightBytes) { + return false + } + if (leftBytes === 0) { + return true + } + if (!leftBuffer.subarray(0, leftBytes).equals(rightBuffer.subarray(0, rightBytes))) { + return false + } + } + } finally { + closeIgnoringErrors(leftDescriptor) + closeIgnoringErrors(rightDescriptor) + } +} + +function readFullChunkSync(descriptor: number, buffer: Buffer): number { + let offset = 0 + while (offset < buffer.length) { + const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, null) + if (bytesRead === 0) { + break + } + offset += bytesRead + } + return offset +} + +function copyFileAtomicallySync(sourcePath: string, targetPath: string): void { + const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.migration.tmp` + try { + copyFileWithWindowsRetry(sourcePath, temporaryPath) + renameFileWithWindowsRetry(temporaryPath, targetPath) + } finally { + rmSync(temporaryPath, { force: true }) + } +} + +function 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}` +} + +function assertWithinCapacity( + reason: CodexLegacySessionSkipReason, + observed: number, + limit: number, + visitedEntryCount: number +): void { + if (!Number.isSafeInteger(observed) || observed > limit) { + throw new CodexLegacySessionCapacityError(reason, observed, limit, visitedEntryCount) + } +} + +function resolveLimits( + overrides: Partial | undefined +): CodexLegacySessionMigrationLimits { + const limits = { ...DEFAULT_CODEX_LEGACY_SESSION_MIGRATION_LIMITS, ...overrides } + for (const [name, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + } + return limits +} + +function closeIgnoringErrors(descriptor: number | null): void { + if (descriptor === null) { + return + } + try { + closeSync(descriptor) + } catch { + // Preserve the comparison result or original read error. + } +} + +function closeDirectoryIgnoringAlreadyClosed(directory: ReturnType): void { + try { + directory.closeSync() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') { + throw error + } + } +} diff --git a/src/main/codex-accounts/legacy-shared-auth-migration.ts b/src/main/codex-accounts/legacy-shared-auth-migration.ts index b21d858cf04..4f1405ba96e 100644 --- a/src/main/codex-accounts/legacy-shared-auth-migration.ts +++ b/src/main/codex-accounts/legacy-shared-auth-migration.ts @@ -1,6 +1,7 @@ -import { lstatSync, readFileSync } from 'node:fs' +import { lstatSync } from 'node:fs' import { join } from 'node:path' import type { CodexManagedAccount } from '../../shared/types' +import { readAgentStateFileSync } from '../agent-state-file-reader' import { writeFileAtomically } from './fs-utils' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { codexAuthMatchesManagedAccount, compareCodexAuthFreshness } from './codex-auth-identity' @@ -174,7 +175,7 @@ function migrateSharedMcpCredentials( function readRegularFile(filePath: string): string | null { const state = regularFileState(filePath) - return state === 'missing' ? null : readFileSync(filePath, 'utf-8') + return state === 'missing' ? null : readAgentStateFileSync(filePath) } function regularFileState(filePath: string): 'missing' | 'present' { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index cb6ba306f2a..435a187fe4a 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -15,9 +15,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { CodexRateLimitAccountsState, GlobalSettings } from '../../shared/types' -import type { ProviderRateLimits, RateLimitState } from '../../shared/rate-limit-types' -import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' -import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger' import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' import type { readHookTrustEntries as ReadHookTrustEntries } from '../codex/config-toml-trust' @@ -189,7 +186,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { } function createStore(settings: GlobalSettings) { - let resetLedger: CodexResetCreditAttemptLedger = { version: 1, attempts: [] } return { getSettings: vi.fn(() => settings), updateSettings: vi.fn((updates: Partial) => { @@ -202,10 +198,6 @@ function createStore(settings: GlobalSettings) { } } return settings - }), - getCodexResetCreditAttemptLedger: vi.fn(() => structuredClone(resetLedger)), - replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn((next: CodexResetCreditAttemptLedger) => { - resetLedger = structuredClone(next) }) } } @@ -220,52 +212,7 @@ function createRateLimits() { function createRuntimeHome() { return { syncForCurrentSelection: vi.fn(), - clearLastWrittenAuthJson: vi.fn(), - prepareForRateLimitFetch: vi.fn(() => null) - } -} - -function createResetCreditLimits(updatedAt = 30): ProviderRateLimits { - return { - provider: 'codex', - session: { - usedPercent: 100, - windowMinutes: 300, - resetsAt: 1_000, - resetDescription: 'soon' - }, - weekly: null, - rateLimitResetCredits: { - availableCount: 1, - totalEarnedCount: 1, - nextExpiresAt: 2_000, - credits: [{ status: 'available', expiresAt: 2_000, grantedAt: 500 }] - }, - updatedAt, - error: null, - status: 'ok' - } -} - -function createResetRateLimitState( - codex: ProviderRateLimits, - target: RateLimitState['codexTarget'] = { runtime: 'host', wslDistro: null } -): RateLimitState { - return { - claude: null, - codex, - gemini: null, - opencodeGo: null, - kimi: null, - antigravity: null, - minimax: null, - grok: null, - minimaxCookieConfigured: false, - grokAuthConfigured: false, - claudeTarget: { runtime: 'host', wslDistro: null }, - codexTarget: target, - inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] + clearLastWrittenAuthJson: vi.fn() } } @@ -2149,1038 +2096,6 @@ describe('CodexAccountService config sync', () => { expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(2) }) - it('validates a reset only after an earlier account switch leaves the mutation queue', async () => { - const firstHome = createManagedHome(testState.userDataDir, 'account-1') - const secondHome = createManagedHome(testState.userDataDir, 'account-2') - const firstAccount = { - id: 'account-1', - email: 'first@example.com', - managedHomePath: firstHome, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [ - firstAccount, - { - ...firstAccount, - id: 'account-2', - email: 'second@example.com', - managedHomePath: secondHome, - updatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - let finishRefresh: (() => void) | undefined - const rateLimits = { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: vi.fn(), - refreshForCodexAccountChange: vi.fn( - () => - new Promise((resolve) => { - finishRefresh = resolve - }) - ) - } - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account: firstAccount, - limits - })! - - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - rateLimits as never, - createRuntimeHome() as never - ) - const selecting = service.selectAccount('account-2') - await vi.waitFor(() => expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledOnce()) - const resetting = service.consumeRateLimitResetCredit( - '11111111-1111-4111-8111-111111111111', - expectedScope - ) - finishRefresh?.() - - await selecting - await expect(resetting).resolves.toMatchObject({ - status: 'rejectedBeforeProvider', - retryDisposition: 'discardAttempt', - reason: 'accountChanged', - scope: expectedScope - }) - expect(rateLimits.consumeCodexRateLimitResetCredit).not.toHaveBeenCalled() - }) - - it('singleflights concurrent same-key reset attempts and forwards the approved home and target', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const nextManagedHomePath = createManagedHome(testState.userDataDir, 'account-2') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const nextAccount = { - ...account, - id: 'account-2', - email: 'next@example.com', - managedHomePath: nextManagedHomePath, - updatedAt: 2 - } - const settings = createSettings({ - codexManagedAccounts: [account, nextAccount], - activeCodexManagedAccountId: account.id - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - let finishConsume: ((value: { outcome: 'reset'; state: RateLimitState }) => void) | undefined - const consume = vi.fn( - () => - new Promise<{ outcome: 'reset'; state: RateLimitState }>((resolve) => { - finishConsume = resolve - }) - ) - const rateLimits = { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume - } - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account, - limits - })! - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - createStore(settings) as never, - rateLimits as never, - createRuntimeHome() as never - ) - const idempotencyKey = '22222222-2222-4222-8222-222222222222' - - const first = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) - const second = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) - expect(second).toBe(first) - await vi.waitFor(() => expect(consume).toHaveBeenCalledOnce()) - const selectingNextAccount = service.selectAccount(nextAccount.id) - finishConsume?.({ outcome: 'reset', state }) - - const resetResults = await Promise.all([first, second]) - expect(resetResults).toMatchObject([ - { outcome: 'reset', scope: expectedScope }, - { outcome: 'reset', scope: expectedScope } - ]) - await selectingNextAccount - expect(resetResults[0]?.codex.activeAccountId).toBe(account.id) - expect(resetResults[0]?.rateLimits).toBe(state) - expect(service.listAccounts().activeAccountId).toBe(nextAccount.id) - expect(consume).toHaveBeenCalledWith({ - idempotencyKey, - target: { runtime: 'host', wslDistro: null }, - codexHomePath: managedHomePath - }) - await expect( - service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) - ).rejects.toThrow('selected Codex account changed') - expect(consume).toHaveBeenCalledOnce() - - await service.selectAccount(account.id) - const settledReplay = await service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) - expect(settledReplay).toMatchObject({ - outcome: 'reset', - scope: expectedScope, - codex: { activeAccountId: account.id } - }) - expect(consume).toHaveBeenCalledOnce() - await expect( - service.consumeRateLimitResetCredit('77777777-7777-4777-8777-777777777777', expectedScope) - ).rejects.toThrow('already attempted') - await expect( - service.consumeRateLimitResetCredit(idempotencyKey, { - ...expectedScope, - offerRevision: 'v1:different' - }) - ).rejects.toThrow('different reset scope') - }) - - it('blocks a different key after an ambiguous provider error but lets desktop retry', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: account.id - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const consume = vi - .fn() - .mockRejectedValueOnce(new Error('provider response lost')) - .mockResolvedValueOnce({ outcome: 'alreadyRedeemed', state }) - const rateLimits = { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume - } - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account, - limits - })! - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - createStore(settings) as never, - rateLimits as never, - createRuntimeHome() as never - ) - const firstKey = '33333333-3333-4333-8333-333333333333' - - await expect(service.consumeRateLimitResetCredit(firstKey, expectedScope)).rejects.toThrow( - 'provider response lost' - ) - await expect(service.consumeCurrentRateLimitResetCredit()).resolves.toMatchObject({ - outcome: 'alreadyRedeemed', - state - }) - expect(consume).toHaveBeenCalledTimes(2) - await expect( - service.consumeRateLimitResetCredit('44444444-4444-4444-8444-444444444444', expectedScope) - ).rejects.toThrow('already attempted') - await expect( - service.consumeRateLimitResetCredit(firstKey, expectedScope) - ).resolves.toMatchObject({ outcome: 'alreadyRedeemed', scope: expectedScope }) - expect(consume).toHaveBeenCalledTimes(2) - }) - - it('hydrates a pending attempt after restart and replays it without current-offer CAS', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: account.id - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const store = createStore(settings) - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account, - limits - })! - const firstConsume = vi.fn().mockRejectedValue(new Error('provider response lost')) - const { CodexAccountService } = await import('./service') - const firstService = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: firstConsume - } as never, - createRuntimeHome() as never - ) - const key = '88888888-8888-4888-8888-888888888888' - - await expect(firstService.consumeRateLimitResetCredit(key, expectedScope)).rejects.toThrow( - 'provider response lost' - ) - state.codex = { - ...limits, - updatedAt: limits.updatedAt + 1, - rateLimitResetCredits: { ...limits.rateLimitResetCredits!, availableCount: 0 } - } - const replayConsume = vi.fn().mockResolvedValue({ outcome: 'alreadyRedeemed', state }) - const restarted = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: replayConsume - } as never, - createRuntimeHome() as never - ) - - await expect( - restarted.consumeRateLimitResetCredit('99999999-9999-4999-8999-999999999999', expectedScope) - ).rejects.toThrow('unknown outcome') - await expect( - restarted.consumeRateLimitResetCredit(key, { - ...expectedScope, - offerRevision: 'v1:different' - }) - ).rejects.toThrow('different reset scope') - await expect(restarted.consumeRateLimitResetCredit(key, expectedScope)).resolves.toMatchObject({ - outcome: 'alreadyRedeemed', - scope: expectedScope - }) - expect(replayConsume).toHaveBeenCalledOnce() - }) - - it('replays a settled outcome after restart without calling the provider', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: account.id - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const store = createStore(settings) - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account, - limits - })! - const firstConsume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) - const { CodexAccountService } = await import('./service') - const firstService = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: firstConsume - } as never, - createRuntimeHome() as never - ) - const key = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - await firstService.consumeRateLimitResetCredit(key, expectedScope) - - const replayConsume = vi.fn() - const restarted = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: replayConsume - } as never, - createRuntimeHome() as never - ) - - await expect(restarted.consumeRateLimitResetCredit(key, expectedScope)).resolves.toMatchObject({ - outcome: 'reset', - scope: expectedScope - }) - await expect( - restarted.consumeRateLimitResetCredit('abababab-abab-4bab-8bab-abababababab', expectedScope) - ).rejects.toThrow('already attempted') - expect(replayConsume).not.toHaveBeenCalled() - }) - - it('never calls the provider when the pending durability barrier fails', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: account.id - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementationOnce(() => { - throw new Error('disk full') - }) - const consume = vi.fn() - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account, - limits - })! - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume - } as never, - createRuntimeHome() as never - ) - - await expect( - service.consumeRateLimitResetCredit('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', expectedScope) - ).rejects.toThrow('disk full') - expect(consume).not.toHaveBeenCalled() - expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) - }) - - it('keeps disk pending when settle persistence fails and recovers with the same key', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: account.id - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const store = createStore(settings) - const persist = store.replaceCodexResetCreditAttemptLedgerAndFlush.getMockImplementation()! - store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation((ledger) => { - if (ledger.attempts[0]?.state === 'settled') { - throw new Error('settle disk full') - } - persist(ledger) - }) - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account, - limits - })! - const firstConsume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) - const { CodexAccountService } = await import('./service') - const firstService = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: firstConsume - } as never, - createRuntimeHome() as never - ) - const key = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' - - await expect(firstService.consumeRateLimitResetCredit(key, expectedScope)).rejects.toThrow( - 'settle disk full' - ) - expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ - { idempotencyKey: key, state: 'providerPending' } - ]) - - store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation(persist) - const replayConsume = vi.fn().mockResolvedValue({ outcome: 'alreadyRedeemed', state }) - const restarted = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: replayConsume - } as never, - createRuntimeHome() as never - ) - await expect(restarted.consumeRateLimitResetCredit(key, expectedScope)).resolves.toMatchObject({ - outcome: 'alreadyRedeemed' - }) - expect(replayConsume).toHaveBeenCalledOnce() - }) - - it('fails only reset operations closed when the durable ledger is corrupt', async () => { - const settings = createSettings() - const store = createStore(settings) - store.getCodexResetCreditAttemptLedger.mockImplementation(() => { - throw new Error('Codex reset-credit attempt ledger is corrupt') - }) - const consume = vi.fn() - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - { - ...createRateLimits(), - consumeCodexRateLimitResetCredit: consume - } as never, - createRuntimeHome() as never - ) - - expect(service.listAccounts()).toMatchObject({ accounts: [] }) - await expect( - service.consumeRateLimitResetCredit('dddddddd-dddd-4ddd-8ddd-dddddddddddd', { - target: { runtime: 'host', wslDistro: null }, - accountId: 'account-host', - accountRevision: 1, - offerRevision: 'v1:offer' - }) - ).rejects.toThrow('Codex reset-credit attempt ledger is corrupt') - await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow( - 'Codex reset-credit attempt ledger is corrupt' - ) - expect(consume).not.toHaveBeenCalled() - }) - - it('rejects a stale offer scope before calling the provider and permits a corrected retry key', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: account.id - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) - const rateLimits = { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume - } - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account, - limits - })! - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - createStore(settings) as never, - rateLimits as never, - createRuntimeHome() as never - ) - const idempotencyKey = '55555555-5555-4555-8555-555555555555' - - await expect( - service.consumeRateLimitResetCredit(idempotencyKey, { - ...expectedScope, - offerRevision: 'v1:stale' - }) - ).resolves.toMatchObject({ - status: 'rejectedBeforeProvider', - retryDisposition: 'discardAttempt', - reason: 'offerChanged', - scope: { ...expectedScope, offerRevision: 'v1:stale' }, - codex: { activeAccountId: account.id }, - rateLimits: state - }) - expect(consume).not.toHaveBeenCalled() - - await expect( - service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) - ).resolves.toMatchObject({ outcome: 'reset' }) - expect(consume).toHaveBeenCalledOnce() - }) - - it('isolates a WSL reset to the selected distro account and immutable managed home', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-wsl') - const account = { - id: 'account-wsl', - email: 'wsl@example.com', - managedHomePath, - managedHomeRuntime: 'wsl' as const, - wslDistro: 'Ubuntu', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: account.id } - } - }) - const limits = createResetCreditLimits() - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const state = createResetRateLimitState(limits, target) - const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) - const rateLimits = { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume - } - const expectedScope = buildCodexResetCreditExpectedScope({ target, account, limits })! - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - createStore(settings) as never, - rateLimits as never, - createRuntimeHome() as never - ) - - await expect( - service.consumeRateLimitResetCredit('66666666-6666-4666-8666-666666666666', expectedScope) - ).resolves.toMatchObject({ scope: expectedScope }) - expect(consume).toHaveBeenCalledWith({ - idempotencyKey: '66666666-6666-4666-8666-666666666666', - target, - codexHomePath: managedHomePath - }) - }) - - it('keeps a restarted pending WSL attempt isolated from another distro', async () => { - const ubuntuHome = createManagedHome(testState.userDataDir, 'account-ubuntu') - const debianHome = createManagedHome(testState.userDataDir, 'account-debian') - const ubuntu = { - id: 'account-ubuntu', - email: 'ubuntu@example.com', - managedHomePath: ubuntuHome, - managedHomeRuntime: 'wsl' as const, - wslDistro: 'Ubuntu', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const debian = { - ...ubuntu, - id: 'account-debian', - email: 'debian@example.com', - managedHomePath: debianHome, - wslDistro: 'Debian', - updatedAt: 2 - } - const settings = createSettings({ - codexManagedAccounts: [ubuntu, debian], - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: ubuntu.id, Debian: debian.id } - } - }) - const limits = createResetCreditLimits() - const ubuntuTarget = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const debianTarget = { runtime: 'wsl' as const, wslDistro: 'Debian' } - const state = createResetRateLimitState(limits, ubuntuTarget) - const ubuntuScope = buildCodexResetCreditExpectedScope({ - target: ubuntuTarget, - account: ubuntu, - limits - })! - const debianScope = buildCodexResetCreditExpectedScope({ - target: debianTarget, - account: debian, - limits - })! - const store = createStore(settings) - const { CodexAccountService } = await import('./service') - const firstService = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: vi - .fn() - .mockRejectedValue(new Error('Ubuntu response lost')) - } as never, - createRuntimeHome() as never - ) - await expect( - firstService.consumeRateLimitResetCredit('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', ubuntuScope) - ).rejects.toThrow('Ubuntu response lost') - - state.codexTarget = debianTarget - const debianConsume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) - const restarted = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: debianConsume - } as never, - createRuntimeHome() as never - ) - - await expect( - restarted.consumeRateLimitResetCredit('ffffffff-ffff-4fff-8fff-ffffffffffff', debianScope) - ).resolves.toMatchObject({ outcome: 'reset', scope: debianScope }) - expect(debianConsume).toHaveBeenCalledWith({ - idempotencyKey: 'ffffffff-ffff-4fff-8fff-ffffffffffff', - target: debianTarget, - codexHomePath: debianHome - }) - }) - - it('preserves desktop reset support for the system-default Codex account', async () => { - const settings = createSettings() - const state = createResetRateLimitState(createResetCreditLimits()) - const consume = vi.fn().mockResolvedValue({ outcome: 'noCredit', state }) - const rateLimits = { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume - } - const runtimeHome = createRuntimeHome() - runtimeHome.prepareForRateLimitFetch.mockReturnValue(null) - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - createStore(settings) as never, - rateLimits as never, - runtimeHome as never - ) - - await expect(service.consumeCurrentRateLimitResetCredit()).resolves.toMatchObject({ - outcome: 'noCredit' - }) - expect(runtimeHome.prepareForRateLimitFetch).toHaveBeenCalledWith({ - runtime: 'host', - wslDistro: null - }) - expect(consume).toHaveBeenCalledWith({ - idempotencyKey: expect.any(String), - target: { runtime: 'host', wslDistro: null }, - codexHomePath: null - }) - }) - - it('routes a managed desktop reset through the durable coordinator', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: account.id - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const store = createStore(settings) - const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume - } as never, - createRuntimeHome() as never - ) - - await expect(service.consumeCurrentRateLimitResetCredit()).resolves.toEqual({ - outcome: 'reset', - state - }) - expect(consume).toHaveBeenCalledWith({ - idempotencyKey: expect.any(String), - target: { runtime: 'host', wslDistro: null }, - codexHomePath: managedHomePath - }) - expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ - { state: 'settled', outcome: 'reset', expectedScope: { accountId: account.id } } - ]) - }) - - it('reuses the durable pending key when desktop retries a managed reset after restart', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: account.id - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const store = createStore(settings) - const firstConsume = vi.fn().mockRejectedValue(new Error('provider response lost')) - const { CodexAccountService } = await import('./service') - const firstService = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: firstConsume - } as never, - createRuntimeHome() as never - ) - - await expect(firstService.consumeCurrentRateLimitResetCredit()).rejects.toThrow( - 'provider response lost' - ) - const pending = store.getCodexResetCreditAttemptLedger().attempts[0] - expect(pending).toMatchObject({ - state: 'providerPending', - expectedScope: { accountId: account.id } - }) - - state.codex = { - ...limits, - updatedAt: limits.updatedAt + 1, - rateLimitResetCredits: { ...limits.rateLimitResetCredits!, availableCount: 0 } - } - const replayConsume = vi.fn().mockResolvedValue({ outcome: 'alreadyRedeemed', state }) - const restarted = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: replayConsume - } as never, - createRuntimeHome() as never - ) - - await expect(restarted.consumeCurrentRateLimitResetCredit()).resolves.toEqual({ - outcome: 'alreadyRedeemed', - state - }) - expect(replayConsume).toHaveBeenCalledWith({ - idempotencyKey: pending?.idempotencyKey, - target: { runtime: 'host', wslDistro: null }, - codexHomePath: managedHomePath - }) - expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ - { state: 'settled', outcome: 'alreadyRedeemed' } - ]) - }) - - it('blocks the system-default fallback while the exact target has a pending attempt', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: null - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account, - limits - })! - const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ - version: 1, - attempts: [ - { - idempotencyKey: '12121212-1212-4212-8212-121212121212', - expectedScope, - state: 'providerPending' - } - ] - }) - const consume = vi.fn() - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume - } as never, - createRuntimeHome() as never - ) - - await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome') - expect(consume).not.toHaveBeenCalled() - }) - - it('unwedges the system-default reset after removing the account owning a pending attempt', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: null - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account, - limits - })! - const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ - version: 1, - attempts: [ - { - idempotencyKey: '12121212-1212-4212-8212-121212121212', - expectedScope, - state: 'providerPending' - } - ] - }) - const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume - } as never, - createRuntimeHome() as never - ) - - // The orphan pending attempt wedges the target-scoped default reset until removal. - await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome') - - await service.removeAccount('account-1') - - await expect(service.consumeCurrentRateLimitResetCredit()).resolves.toEqual({ - outcome: 'reset', - state - }) - expect(consume).toHaveBeenCalledTimes(1) - expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) - }) - - it('keeps reset attempts fail-closed when removal cannot persist their purge', async () => { - const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') - const account = { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - managedHomeRuntime: 'host' as const, - wslDistro: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - const settings = createSettings({ - codexManagedAccounts: [account], - activeCodexManagedAccountId: null - }) - const limits = createResetCreditLimits() - const state = createResetRateLimitState(limits) - const expectedScope = buildCodexResetCreditExpectedScope({ - target: state.codexTarget, - account, - limits - })! - const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ - version: 1, - attempts: [ - { - idempotencyKey: '13131313-1313-4313-8313-131313131313', - expectedScope, - state: 'providerPending' - } - ] - }) - const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume - } as never, - createRuntimeHome() as never - ) - vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockImplementationOnce(() => { - throw new Error('disk full') - }) - - await expect(service.removeAccount('account-1')).rejects.toThrow('disk full') - await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome') - expect(consume).not.toHaveBeenCalled() - }) - - it('does not reset a different system-default target after waiting in the mutation queue', async () => { - const settings = createSettings() - const state = createResetRateLimitState(createResetCreditLimits()) - let finishRefresh: (() => void) | undefined - const consume = vi.fn() - const rateLimits = { - ...createRateLimits(), - getState: vi.fn(() => state), - consumeCodexRateLimitResetCredit: consume, - refreshForCodexAccountChange: vi.fn( - () => - new Promise((resolve) => { - finishRefresh = resolve - }) - ) - } - const runtimeHome = createRuntimeHome() - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - createStore(settings) as never, - rateLimits as never, - runtimeHome as never - ) - - const queueBlocker = service.selectAccount(null) - await vi.waitFor(() => expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledOnce()) - const resetting = service.consumeCurrentRateLimitResetCredit() - state.codexTarget = { runtime: 'wsl', wslDistro: 'Ubuntu' } - finishRefresh?.() - - await queueBlocker - await expect(resetting).rejects.toThrow('target changed') - expect(consume).not.toHaveBeenCalled() - expect(runtimeHome.prepareForRateLimitFetch).not.toHaveBeenCalled() - }) - it('removes command listeners when Codex login times out', async () => { vi.resetModules() vi.useFakeTimers() diff --git a/src/main/codex-cli/nvm-version-directory-discovery.test.ts b/src/main/codex-cli/nvm-version-directory-discovery.test.ts new file mode 100644 index 00000000000..c2d725dec1c --- /dev/null +++ b/src/main/codex-cli/nvm-version-directory-discovery.test.ts @@ -0,0 +1,93 @@ +import { join } from 'node:path' +import type * as NodeFs from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const opendirSyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + opendirSync: opendirSyncMock +})) + +import { discoverNvmVersionBinDirectories } from './nvm-version-directory-discovery' + +type FakeEntry = { name: string; directory?: boolean } + +function useEntries(entries: FakeEntry[]): { closeSync: ReturnType } { + let index = 0 + const closeSync = vi.fn() + opendirSyncMock.mockReturnValue({ + closeSync, + readSync: vi.fn(() => { + const entry = entries[index] + index += 1 + return entry + ? { + name: entry.name, + isDirectory: () => entry.directory !== false + } + : null + }) + }) + return { closeSync } +} + +describe('nvm version directory discovery', () => { + beforeEach(() => { + opendirSyncMock.mockReset() + }) + + it('preserves newest-version ordering for an ordinary listing', () => { + useEntries([ + { name: 'v20.18.0' }, + { name: 'README', directory: false }, + { name: 'v24.2.0' }, + { name: 'v22.14.0' } + ]) + + expect(discoverNvmVersionBinDirectories('/home/alice')).toEqual([ + join('/home/alice', '.nvm', 'versions', 'node', 'v24.2.0', 'bin'), + join('/home/alice', '.nvm', 'versions', 'node', 'v22.14.0', 'bin'), + join('/home/alice', '.nvm', 'versions', 'node', 'v20.18.0', 'bin') + ]) + }) + + it('accepts the exact entry and retained-name limits', () => { + const { closeSync } = useEntries([{ name: 'v20' }, { name: 'v22' }]) + + expect( + discoverNvmVersionBinDirectories('/home/alice', { + maxEntries: 2, + maxRetainedNameBytes: 6 + }) + ).toHaveLength(2) + expect(closeSync).toHaveBeenCalledOnce() + }) + + it('fails closed and closes the stream on the first entry over the count limit', () => { + const { closeSync } = useEntries([ + { name: 'v20' }, + { name: 'v22' }, + { name: 'v24' }, + { name: 'v26' } + ]) + + expect( + discoverNvmVersionBinDirectories('/home/alice', { + maxEntries: 2 + }) + ).toEqual([]) + expect(closeSync).toHaveBeenCalledOnce() + }) + + it('fails closed when retained directory names exceed the byte limit', () => { + const { closeSync } = useEntries([{ name: 'v20' }, { name: 'v22' }]) + + expect( + discoverNvmVersionBinDirectories('/home/alice', { + maxRetainedNameBytes: 5 + }) + ).toEqual([]) + expect(closeSync).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/codex-cli/nvm-version-directory-discovery.ts b/src/main/codex-cli/nvm-version-directory-discovery.ts new file mode 100644 index 00000000000..c537eef95b0 --- /dev/null +++ b/src/main/codex-cli/nvm-version-directory-discovery.ts @@ -0,0 +1,100 @@ +import { opendirSync } from 'node:fs' +import { join } from 'node:path' + +export const NVM_VERSION_DISCOVERY_MAX_ENTRIES = 4_096 +export const NVM_VERSION_DISCOVERY_MAX_RETAINED_NAME_BYTES = 1024 * 1024 + +export type NvmVersionDiscoveryLimits = { + maxEntries: number + maxRetainedNameBytes: number +} + +function parseVersionSegment(raw: string): number[] { + return raw + .replace(/^v/i, '') + .split('.') + .map((segment) => Number.parseInt(segment, 10)) + .map((segment) => (Number.isFinite(segment) ? segment : 0)) +} + +function compareVersionDesc(left: string, right: string): number { + const leftParts = parseVersionSegment(left) + const rightParts = parseVersionSegment(right) + const length = Math.max(leftParts.length, rightParts.length) + + for (let index = 0; index < length; index += 1) { + const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0) + if (delta !== 0) { + return delta + } + } + + return right.localeCompare(left) +} + +function resolveLimit(requested: number | undefined, maximum: number, name: string): number { + if (requested === undefined) { + return maximum + } + if (!Number.isSafeInteger(requested) || requested < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + return Math.min(requested, maximum) +} + +function closeNvmVersionsDirectory(directory: ReturnType): void { + try { + directory.closeSync() + } catch { + // The OS may already have closed a fully consumed directory stream. + } +} + +export function discoverNvmVersionBinDirectories( + homePath: string, + requestedLimits: Partial = {} +): string[] { + const maxEntries = resolveLimit( + requestedLimits.maxEntries, + NVM_VERSION_DISCOVERY_MAX_ENTRIES, + 'maxEntries' + ) + const maxRetainedNameBytes = resolveLimit( + requestedLimits.maxRetainedNameBytes, + NVM_VERSION_DISCOVERY_MAX_RETAINED_NAME_BYTES, + 'maxRetainedNameBytes' + ) + const versionsDir = join(homePath, '.nvm', 'versions', 'node') + let directory: ReturnType + try { + directory = opendirSync(versionsDir, { bufferSize: 32 }) + } catch { + return [] + } + + const versionNames: string[] = [] + let scannedEntries = 0 + let retainedNameBytes = 0 + try { + for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) { + scannedEntries += 1 + if (scannedEntries > maxEntries) { + return [] + } + if (!entry.isDirectory()) { + continue + } + retainedNameBytes += Buffer.byteLength(entry.name, 'utf8') + if (retainedNameBytes > maxRetainedNameBytes) { + return [] + } + versionNames.push(entry.name) + } + } finally { + closeNvmVersionsDirectory(directory) + } + + return versionNames + .sort(compareVersionDesc) + .map((versionName) => join(versionsDir, versionName, 'bin')) +} diff --git a/src/main/codex-usage/scanner-large-directory.test.ts b/src/main/codex-usage/scanner-large-directory.test.ts index 1ef57ff9d03..6a7e4aa5099 100644 --- a/src/main/codex-usage/scanner-large-directory.test.ts +++ b/src/main/codex-usage/scanner-large-directory.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Dirent, Stats } from 'node:fs' +import type { Dir, Dirent, Stats } from 'node:fs' import type * as FsPromises from 'node:fs/promises' import { join } from 'node:path' -const { getLegacyCopiedCodexSessionBridgeScanPreferenceMock, readdirMock, statMock } = vi.hoisted( +const { getLegacyCopiedCodexSessionBridgeScanPreferenceMock, opendirMock, statMock } = vi.hoisted( () => ({ getLegacyCopiedCodexSessionBridgeScanPreferenceMock: vi.fn(), - readdirMock: vi.fn<(dirPath: string) => Promise>(), + opendirMock: vi.fn<(dirPath: string) => Promise>(), statMock: vi.fn<(filePath: string) => Promise>() }) ) @@ -15,7 +15,7 @@ vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') return { ...actual, - readdir: readdirMock, + opendir: opendirMock, stat: statMock } }) @@ -49,26 +49,44 @@ const largeSessionEntries = Array.from({ length: FILE_COUNT }, (_, index) => dirent(`session-${index}.jsonl`, 'file') ) +function directory(entries: Dirent[]): Dir { + return { + async *[Symbol.asyncIterator]() { + yield* entries + } + } as Dir +} + +function generatedFileDirectory(count: number): Dir { + return { + async *[Symbol.asyncIterator]() { + for (let index = 0; index < count; index++) { + yield dirent(`session-${index}.jsonl`, 'file') + } + } + } as Dir +} + describe('listCodexSessionFiles large directories', () => { beforeEach(() => { getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReset() getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReturnValue(null) - readdirMock.mockReset() + opendirMock.mockReset() statMock.mockReset() }) it('keeps nested session scans past the JavaScript spread-argument limit', async () => { - readdirMock.mockImplementation(async (dirPath) => { + opendirMock.mockImplementation(async (dirPath) => { if (dirPath === RUNTIME_SESSIONS_ROOT) { - return [dirent('bulk', 'directory')] + return directory([dirent('bulk', 'directory')]) } if (dirPath === RUNTIME_BULK_DIR) { - return largeSessionEntries + return directory(largeSessionEntries) } if (dirPath === SYSTEM_SESSIONS_ROOT) { - return [] + return directory([]) } - throw new Error(`Unexpected readdir path: ${dirPath}`) + throw new Error(`Unexpected opendir path: ${dirPath}`) }) statMock.mockImplementation(async (filePath) => { const match = /session-(\d+)\.jsonl$/.exec(filePath.replaceAll('\\', '/')) @@ -83,4 +101,24 @@ describe('listCodexSessionFiles large directories', () => { await expect(listCodexSessionFiles()).resolves.toHaveLength(FILE_COUNT) expect(getLegacyCopiedCodexSessionBridgeScanPreferenceMock).not.toHaveBeenCalled() }) + + it('fails closed instead of silently omitting a rollout past capacity', async () => { + opendirMock.mockImplementation(async (dirPath) => { + if (dirPath === RUNTIME_SESSIONS_ROOT) { + return generatedFileDirectory(200_001) + } + if (dirPath === SYSTEM_SESSIONS_ROOT) { + return directory([]) + } + throw new Error(`Unexpected opendir path: ${dirPath}`) + }) + + const { listCodexSessionFiles } = await import('./scanner') + + await expect(listCodexSessionFiles()).rejects.toMatchObject({ + name: 'UsageHistoryScanCapacityError', + resource: 'files', + limit: 200_000 + }) + }) }) diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index 6426222461f..e31a5b4f476 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>() @@ -10,7 +13,8 @@ vi.mock('electron', () => ({ } })) -import { attributeCodexUsageEvent, parseCodexUsageRecord } from './scanner' +import { attributeCodexUsageEvent, parseCodexUsageFile, parseCodexUsageRecord } from './scanner' +import { UsageHistoryScanBudget } from '../usage-history-scan-budget' describe('parseCodexUsageRecord', () => { it('uses token totals only as a duplicate baseline', () => { @@ -321,3 +325,37 @@ describe('attributeCodexUsageEvent', () => { expect(attributed?.worktreeId).toBeNull() }) }) + +describe('parseCodexUsageFile capacity', () => { + it('fails the whole parse when retained usage events exceed capacity', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-capacity-')) + const filePath = join(root, 'rollout.jsonl') + const event = (timestamp: string, total: number) => + JSON.stringify({ + timestamp, + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: total, total_tokens: total }, + last_token_usage: { input_tokens: 1, total_tokens: 1 } + } + } + }) + try { + await writeFile( + filePath, + [event('2026-04-09T10:00:00.000Z', 1), event('2026-04-09T10:00:01.000Z', 2)].join('\n') + ) + const budget = new UsageHistoryScanBudget({ records: 1 }) + + await expect(parseCodexUsageFile(filePath, [], { budget })).rejects.toMatchObject({ + name: 'UsageHistoryScanCapacityError', + resource: 'records', + limit: 1 + }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index eb5fbcaab14..a837b8df676 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -1,14 +1,21 @@ /* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */ import { basename, join, win32, posix } from 'node:path' -import { createReadStream, existsSync } from 'node:fs' -import { realpath, readdir, stat } from 'node:fs/promises' -import { createInterface } from 'node:readline' +import { existsSync } from 'node:fs' +import { realpath, stat } from 'node:fs/promises' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import { getCodexAccountHomeSessionDirectories } from '../codex/codex-account-home-discovery' import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' +import { walkUsageHistoryJsonlFiles } from '../usage-history-file-discovery' +import { readUsageHistoryJsonlLines } from '../usage-history-jsonl-reader' +import { + MAX_USAGE_HISTORY_FILES, + UsageHistoryScanBudget, + UsageHistoryScanCapacityError, + getUsageHistoryRetainedBytes +} from '../usage-history-scan-budget' import type { CodexUsageAttributedEvent, CodexUsageDailyAggregate, @@ -93,39 +100,6 @@ async function yieldToEventLoop(): Promise { await new Promise((resolve) => setImmediate(resolve)) } -async function walkJsonlFiles( - dirPath: string, - progress: { entriesVisited: number } = { entriesVisited: 0 } -): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - const files: string[] = [] - - for (const entry of entries) { - progress.entriesVisited += 1 - if (progress.entriesVisited % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { - await yieldToEventLoop() - } - const fullPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - appendDiscoveredFiles(files, await walkJsonlFiles(fullPath, progress)) - continue - } - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(fullPath) - } - } - - return files -} - -function appendDiscoveredFiles(target: string[], source: readonly string[]): void { - // Why: large session directories can exceed V8's argument limit if child - // file arrays are spread into push(). - for (const filePath of source) { - target.push(filePath) - } -} - export function getCodexSessionsDirectory(): string { // Why: Orca-launched Codex processes receive an Orca-owned CODEX_HOME, so // callers that need the primary runtime path should not consult ambient @@ -148,12 +122,19 @@ function hasLegacyCopiedSessionBridgeMarkers(): boolean { return existsSync(join(getOrcaManagedCodexHomePath(), '.orca-session-copies')) } -export async function listCodexSessionFiles(): Promise { +export async function listCodexSessionFiles( + budget = new UsageHistoryScanBudget() +): Promise { const files: string[] = [] for (const dirPath of getCodexSessionDirectories()) { try { - appendDiscoveredFiles(files, await walkJsonlFiles(dirPath)) - } catch { + for (const filePath of await walkUsageHistoryJsonlFiles(dirPath, budget)) { + files.push(filePath) + } + } catch (error) { + if (error instanceof UsageHistoryScanCapacityError) { + throw error + } // Missing or unreadable history in one home should not hide the other. } } @@ -894,6 +875,62 @@ function mergeDailyAggregates( } } +function claimCodexUsageProjection( + budget: UsageHistoryScanBudget, + sessions: readonly CodexUsageSession[], + dailyAggregates: readonly CodexUsageDailyAggregate[] +): void { + for (const session of sessions) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + session.sessionId, + session.firstTimestamp, + session.lastTimestamp, + session.primaryModel, + session.primaryProjectLabel, + session.primaryWorktreeId, + session.primaryRepoId + ]) + ) + for (const location of session.locationBreakdown) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + location.locationKey, + location.projectLabel, + location.repoId, + location.worktreeId + ]) + ) + } + for (const model of session.modelBreakdown) { + budget.claimProjection(getUsageHistoryRetainedBytes([model.modelKey, model.modelLabel])) + } + for (const locationModel of session.locationModelBreakdown) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + locationModel.locationKey, + locationModel.modelKey, + locationModel.modelLabel, + locationModel.repoId, + locationModel.worktreeId + ]) + ) + } + } + for (const daily of dailyAggregates) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + daily.day, + daily.model, + daily.projectKey, + daily.projectLabel, + daily.repoId, + daily.worktreeId + ]) + ) + } +} + export function parseCodexUsageRecord( line: string, context: CodexUsageParseContext @@ -997,16 +1034,14 @@ export function parseCodexUsageRecord( export async function parseCodexUsageFile( filePath: string, worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[], - options: { skipInitialBytes?: number; claimEventKey?: (eventKey: string) => boolean } = {} + options: { + skipInitialBytes?: number + claimEventKey?: (eventKey: string) => boolean + budget?: UsageHistoryScanBudget + } = {} ): Promise { + const budget = options.budget ?? new UsageHistoryScanBudget() const processedFile = await getProcessedFileInfo(filePath) - const lines = createInterface({ - input: createReadStream(filePath, { - encoding: 'utf-8', - start: options.skipInitialBytes ?? 0 - }), - crlfDelay: Infinity - }) const events: CodexUsageAttributedEvent[] = [] const context: CodexUsageParseContext = { sessionId: basename(filePath, '.jsonl'), @@ -1021,7 +1056,9 @@ export async function parseCodexUsageFile( const ownedEventKeys = new Set() let hasDeferredClaims = false - for await (const line of lines) { + for await (const line of readUsageHistoryJsonlLines(filePath, { + start: options.skipInitialBytes ?? 0 + })) { const parsed = parseCodexUsageRecord(line, context) if (!parsed) { continue @@ -1033,6 +1070,18 @@ export async function parseCodexUsageFile( hasDeferredClaims = true continue } + budget.claimRecord( + getUsageHistoryRetainedBytes([ + parsed.sessionId, + parsed.timestamp, + parsed.eventKey, + parsed.cwd, + parsed.model + ]) + ) + if (!ownedEventKeys.has(parsed.eventKey)) { + budget.claimOwnershipKey(parsed.eventKey) + } ownedEventKeys.add(parsed.eventKey) const attributed = await attributeCodexUsageEvent(parsed, worktrees) if (attributed) { @@ -1040,9 +1089,11 @@ export async function parseCodexUsageFile( } } + const aggregates = aggregateCodexUsage(events) + claimCodexUsageProjection(budget, aggregates.sessions, aggregates.dailyAggregates) return { ...processedFile, - ...aggregateCodexUsage(events), + ...aggregates, ownedEventKeys: [...ownedEventKeys], hasDeferredClaims } @@ -1056,7 +1107,14 @@ export async function scanCodexUsageFiles( sessions: CodexUsageSession[] dailyAggregates: CodexUsageDailyAggregate[] }> { - const files = await listCodexSessionFiles() + if (previousProcessedFiles.length > MAX_USAGE_HISTORY_FILES) { + throw new UsageHistoryScanCapacityError('files', MAX_USAGE_HISTORY_FILES) + } + const budget = new UsageHistoryScanBudget() + const files = await listCodexSessionFiles(budget) + for (const previous of previousProcessedFiles) { + budget.claimPath(previous.path) + } const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file])) const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees) const legacySourceSkipBytesByPath = getLegacySourceSkipBytesByPath(files) @@ -1107,7 +1165,12 @@ export async function scanCodexUsageFiles( // deterministic. const eventOwnerByKey = new Map() for (const [filePath, previous] of reusedByPath) { + for (const session of previous.sessions) { + budget.claimRecords(session.eventCount) + } + claimCodexUsageProjection(budget, previous.sessions, previous.dailyAggregates) for (const eventKey of previous.ownedEventKeys) { + budget.claimOwnershipKey(eventKey) // First cached claim wins so conflicting projections stay deterministic. if (!eventOwnerByKey.has(eventKey)) { eventOwnerByKey.set(eventKey, filePath) @@ -1119,6 +1182,7 @@ export async function scanCodexUsageFiles( for (const [index, filePath] of pathsToParse.entries()) { const processed = await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, { skipInitialBytes: legacySourceSkipBytesByPath.get(filePath) ?? 0, + budget, claimEventKey: (eventKey) => { const owner = eventOwnerByKey.get(eventKey) if (owner !== undefined && owner !== filePath) { @@ -1151,14 +1215,17 @@ export async function scanCodexUsageFiles( mergeDailyAggregates(dailyByKey, processed.dailyAggregates) } + const sessions = finalizeSessions(sessionsById) + const dailyAggregates = [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + claimCodexUsageProjection(budget, sessions, dailyAggregates) return { processedFiles, - sessions: finalizeSessions(sessionsById), - dailyAggregates: [...dailyByKey.values()].sort((left, right) => - left.day === right.day - ? left.projectLabel.localeCompare(right.projectLabel) - : left.day.localeCompare(right.day) - ) + sessions, + dailyAggregates } } diff --git a/src/main/codex/codex-app-server-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts index c85c4a1c479..c20d7090462 100644 --- a/src/main/codex/codex-app-server-grant-bridge.ts +++ b/src/main/codex/codex-app-server-grant-bridge.ts @@ -16,6 +16,10 @@ import type { CodexUserHookTrustRebaseRequest, CodexUserHookTrustRebaseResult } from './codex-user-hook-trust-rebase-client' +import { + findLastNonEmptyCodexAppServerGrantLine, + parseCodexAppServerGrantJson +} from './codex-app-server-grant-json' // Why: hook install/refresh is synchronous launch prep — a Codex pane must // not start before its trust is settled — but a stdio JSON-RPC session needs @@ -114,12 +118,11 @@ function runCodexAppServerEntrySync( `codex trust-grant entry killed by ${spawned.signal} after ${request.invocation.timeoutMs}ms deadline` ) } - const lines = (spawned.stdout ?? '').split('\n').filter((line) => line.trim().length > 0) - const lastLine = lines.at(-1) + const lastLine = findLastNonEmptyCodexAppServerGrantLine(spawned.stdout ?? '') let envelope: GrantEntryEnvelope | null = null if (lastLine) { try { - envelope = JSON.parse(lastLine) as GrantEntryEnvelope + envelope = parseCodexAppServerGrantJson(lastLine) } catch { envelope = null } diff --git a/src/main/codex/codex-app-server-grant-json.test.ts b/src/main/codex/codex-app-server-grant-json.test.ts new file mode 100644 index 00000000000..8a2178b799b --- /dev/null +++ b/src/main/codex/codex-app-server-grant-json.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CODEX_APP_SERVER_GRANT_JSON_MAX_STRUCTURAL_TOKENS, + findLastNonEmptyCodexAppServerGrantLine, + parseCodexAppServerGrantJson +} from './codex-app-server-grant-json' + +describe('Codex app-server grant JSON admission', () => { + it('preserves the last non-empty output line without splitting all output', () => { + expect(findLastNonEmptyCodexAppServerGrantLine('diagnostic\n\n{"ok":true}\n \t\r')).toBe( + '{"ok":true}' + ) + expect(findLastNonEmptyCodexAppServerGrantLine('\n \t\r')).toBeNull() + }) + + it('rejects structurally amplified grant JSON before parsing', () => { + const text = `{"values":[${'0,'.repeat(CODEX_APP_SERVER_GRANT_JSON_MAX_STRUCTURAL_TOKENS)}0]}` + const parseSpy = vi.spyOn(JSON, 'parse') + try { + expect(() => parseCodexAppServerGrantJson(text)).toThrow(/JSON structure exceeds/) + expect(parseSpy).not.toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } + }) +}) diff --git a/src/main/codex/codex-app-server-grant-json.ts b/src/main/codex/codex-app-server-grant-json.ts new file mode 100644 index 00000000000..bad25efa950 --- /dev/null +++ b/src/main/codex/codex-app-server-grant-json.ts @@ -0,0 +1,51 @@ +import { assertJsonTextStructureWithinLimits } from '../../shared/memory-safety/json-text-structure-limit' + +export const CODEX_APP_SERVER_GRANT_JSON_MAX_STRUCTURAL_TOKENS = 1_000_000 +export const CODEX_APP_SERVER_GRANT_JSON_MAX_NESTING_DEPTH = 128 + +export function parseCodexAppServerGrantJson(text: string): T { + assertJsonTextStructureWithinLimits(text, { + structuralTokens: CODEX_APP_SERVER_GRANT_JSON_MAX_STRUCTURAL_TOKENS, + nestingDepth: CODEX_APP_SERVER_GRANT_JSON_MAX_NESTING_DEPTH + }) + return JSON.parse(text) as T +} + +export function findLastNonEmptyCodexAppServerGrantLine(output: string): string | null { + let end = output.length + while (end > 0) { + const newline = output.lastIndexOf('\n', end - 1) + const start = newline + 1 + const line = output.slice(start, end) + if (hasNonWhitespace(line)) { + return line + } + end = newline === -1 ? 0 : newline + } + return null +} + +function hasNonWhitespace(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (!isEcmaWhitespace(value.charCodeAt(index))) { + return true + } + } + return false +} + +function isEcmaWhitespace(code: number): boolean { + return ( + code === 0x20 || + (code >= 0x09 && code <= 0x0d) || + code === 0xa0 || + code === 0x1680 || + (code >= 0x2000 && code <= 0x200a) || + code === 0x2028 || + code === 0x2029 || + code === 0x202f || + code === 0x205f || + code === 0x3000 || + code === 0xfeff + ) +} diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 04748e6eaa2..eea85544eb9 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import { waitForProcessExitUntil } from './codex-process-exit-deadline' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' @@ -117,7 +118,7 @@ export async function runCodexAppServerSession( windowsHide: true }) as ChildProcessWithoutNullStreams - let stderrTail = '' + const stderrTail = new GrowingByteBuffer() let exited = false let nextRequestId = 1 let timedOut = false @@ -145,10 +146,15 @@ export async function runCodexAppServerSession( child.on('close', () => { failPending(buildEarlyExitError()) }) - // Why: JSONL can contain non-ASCII hook paths. Stream decoding must retain a - // multibyte character split across pipe chunks or the response becomes invalid JSON. - child.stderr.setEncoding('utf8').on('data', (chunk: string) => { - stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) + // Why: retain raw bytes so split UTF-8 code points decode only after a full line arrives. + child.stderr.on('data', (chunk: Buffer) => { + if (chunk.byteLength >= STDERR_TAIL_MAX_BYTES) { + stderrTail.clear() + stderrTail.append(chunk.subarray(chunk.byteLength - STDERR_TAIL_MAX_BYTES)) + return + } + stderrTail.append(chunk) + stderrTail.retainSuffix(STDERR_TAIL_MAX_BYTES) }) // Why: a child can exit between the liveness check and stdin.write(); an // EPIPE must reject the RPC instead of becoming an unhandled stream error. @@ -156,21 +162,25 @@ export async function runCodexAppServerSession( failPending(error) }) - let stdoutBuffer = '' - child.stdout.setEncoding('utf8').on('data', (chunk: string) => { - stdoutBuffer += chunk - if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) { + const stdoutBuffer = new GrowingByteBuffer() + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer.append(chunk) + if (stdoutBuffer.byteLength > STDOUT_LINE_MAX_BYTES) { // Why: Windows process-tree termination is asynchronous; stop buffered // chunks from spawning another taskkill for the same oversized response. child.stdout.destroy() + stdoutBuffer.clear() killCodexAppServerProcessTree(child) failPending(new Error('codex app-server emitted an oversized JSONL response')) return } - let newlineIndex - while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) { - const line = stdoutBuffer.slice(0, newlineIndex).trim() - stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) + while (true) { + const newlineIndex = stdoutBuffer.indexOfByte(0x0a) + if (newlineIndex === -1) { + break + } + const line = stdoutBuffer.takePrefixString(newlineIndex).trim() + stdoutBuffer.discardPrefix(1) if (!line) { continue } @@ -263,13 +273,14 @@ export async function runCodexAppServerSession( } function buildEarlyExitError(): Error { - if (stderrIndicatesMissingAppServer(stderrTail)) { + const stderr = stderrTail.toString() + if (stderrIndicatesMissingAppServer(stderr)) { return new CodexAppServerUnsupportedError( - `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` + `codex CLI does not support the app-server subcommand: ${stderr.trim().slice(0, 400)}` ) } return new Error( - `codex app-server exited before completing the session${stderrTail ? `: ${stderrTail.trim().slice(0, 400)}` : ''}` + `codex app-server exited before completing the session${stderr ? `: ${stderr.trim().slice(0, 400)}` : ''}` ) } @@ -289,10 +300,11 @@ export async function runCodexAppServerSession( error instanceof Error && !(error instanceof CodexAppServerUnsupportedError) && !(error instanceof CodexAppServerTimeoutError) && - stderrIndicatesMissingAppServer(stderrTail) + stderrIndicatesMissingAppServer(stderrTail.toString()) ) { + const stderr = stderrTail.toString() throw new CodexAppServerUnsupportedError( - `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` + `codex CLI does not support the app-server subcommand: ${stderr.trim().slice(0, 400)}` ) } throw error @@ -312,5 +324,7 @@ export async function runCodexAppServerSession( } } clearTimeout(deadline) + stdoutBuffer.clear() + stderrTail.clear() } } diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index 61e2eba1eca..e2f042f3d79 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -3,7 +3,6 @@ import { existsSync, lstatSync, mkdirSync, - readFileSync, readlinkSync, rmdirSync, rmSync, @@ -14,8 +13,11 @@ import { } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' +import { nodeSourceAndCopyContentsEqualSync } from '../../shared/node-source-copy-content-equality' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' const CODEX_GLOBAL_INSTRUCTIONS_ENTRY = 'AGENTS.md' +const CODEX_RESOURCE_COPY_MARKER_MAX_BYTES = 64 * 1024 const CODEX_SYSTEM_RESOURCE_ENTRIES = [ 'skills', @@ -124,7 +126,7 @@ function linkSystemCodexResource( // rewriting an unchanged file across the UNC boundary on every launch. if ( entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && - copiedFileContentsMatch(sourcePath, targetPath) + nodeSourceAndCopyContentsEqualSync(sourcePath, targetPath) ) { return } @@ -213,19 +215,6 @@ function pathEntryExists(entryPath: string): boolean { } } -function copiedFileContentsMatch(sourcePath: string, targetPath: string): boolean { - try { - // Why: reading a FIFO or device synchronously can block Codex launch. - // Follow source symlinks, but only compare two regular files. - if (!statSync(sourcePath).isFile() || !lstatSync(targetPath).isFile()) { - return false - } - return readFileSync(sourcePath).equals(readFileSync(targetPath)) - } catch { - return false - } -} - function targetAlreadyPointsToSource(targetPath: string, sourcePath: string): boolean { try { return ( @@ -264,7 +253,10 @@ function markCopiedResource(managedHomePath: string, entryName: string, sourcePa function readCopiedResourceSourcePath(managedHomePath: string, entryName: string): string | null { try { const parsed: unknown = JSON.parse( - readFileSync(getResourceCopyMarkerPath(managedHomePath, entryName), 'utf-8') + readNodeFileSyncWithinLimit( + getResourceCopyMarkerPath(managedHomePath, entryName), + CODEX_RESOURCE_COPY_MARKER_MAX_BYTES + ).buffer.toString('utf8') ) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index 1d59c17ac14..769d99ec21d 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -1,10 +1,11 @@ -import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync } from 'node:fs' +import { existsSync, mkdirSync, statSync, unlinkSync } from 'node:fs' import { join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { buildManagedCommandHook, createManagedCommandMatcher, MANAGED_HOOK_TIMEOUT_SECONDS, + readHooksJsonRawForGenerationCheck, readHooksJsonWithRaw, removeManagedCommands, writeHooksJson, @@ -74,7 +75,9 @@ function assertHooksJsonGeneration( hooksWritePath: string, expectedRaw: string | null ): void { - const currentRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null + const currentRaw = existsSync(hooksJsonPath) + ? readHooksJsonRawForGenerationCheck(hooksJsonPath) + : null if (currentRaw !== expectedRaw || resolveHooksJsonWritePath(hooksJsonPath) !== hooksWritePath) { // Why: the pre-mutation RPC can overlap a user's editor save. Abort rather // than atomically replacing a newer file with the stale parsed snapshot. diff --git a/src/main/codex/codex-session-file-listing.test.ts b/src/main/codex/codex-session-file-listing.test.ts new file mode 100644 index 00000000000..59ea9f9f14e --- /dev/null +++ b/src/main/codex/codex-session-file-listing.test.ts @@ -0,0 +1,112 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CodexSessionListingCapacityError, + listCodexSessionJsonlFilesIncrementally, + listCodexSessionJsonlFilesWithinLimits +} from './codex-session-file-listing' + +const tempRoots: string[] = [] + +async function makeRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-session-listing-')) + tempRoots.push(root) + return root +} + +async function collect(files: AsyncIterable): Promise { + const collected: string[] = [] + for await (const file of files) { + collected.push(file) + } + return collected +} + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Codex session file-listing limits', () => { + it('preserves sorted JSONL discovery below every limit', async () => { + const root = await makeRoot() + await mkdir(join(root, '2026', '01', '02'), { recursive: true }) + const later = join(root, '2026', '01', '02', 'z.jsonl') + const earlier = join(root, '2026', '01', '02', 'a.jsonl') + await writeFile(later, '{}') + await writeFile(earlier, '{}') + await writeFile(join(root, '2026', '01', '02', 'ignored.txt'), 'x') + + expect(listCodexSessionJsonlFilesWithinLimits(root)).toEqual([earlier, later]) + }) + + it('accepts exact entry and file limits, then rejects the next file', async () => { + const root = await makeRoot() + await writeFile(join(root, 'a.jsonl'), '{}') + await writeFile(join(root, 'b.jsonl'), '{}') + + expect( + listCodexSessionJsonlFilesWithinLimits(root, { maxEntries: 2, maxFiles: 2 }) + ).toHaveLength(2) + await writeFile(join(root, 'c.jsonl'), '{}') + expect(() => + listCodexSessionJsonlFilesWithinLimits(root, { maxEntries: 3, maxFiles: 2 }) + ).toThrow('Codex session listing exceeded 2 files') + }) + + it('bounds a zero-file directory tree by entries and depth', async () => { + const root = await makeRoot() + await mkdir(join(root, 'a', 'b'), { recursive: true }) + + expect(listCodexSessionJsonlFilesWithinLimits(root, { maxEntries: 2, maxDepth: 2 })).toEqual([]) + expect(() => + listCodexSessionJsonlFilesWithinLimits(root, { maxEntries: 1, maxDepth: 2 }) + ).toThrow('Codex session listing exceeded 1 entries') + expect(() => + listCodexSessionJsonlFilesWithinLimits(root, { maxEntries: 2, maxDepth: 1 }) + ).toThrow('Codex session listing exceeded 1 depth') + }) + + it('accepts the exact aggregate path limit and rejects one code unit less', async () => { + const root = await makeRoot() + const filePath = join(root, 'a.jsonl') + await writeFile(filePath, '{}') + const exactPathCodeUnits = root.length + filePath.length + + expect( + listCodexSessionJsonlFilesWithinLimits(root, { + maxPathCodeUnits: exactPathCodeUnits + }) + ).toEqual([filePath]) + expect(() => + listCodexSessionJsonlFilesWithinLimits(root, { + maxPathCodeUnits: exactPathCodeUnits - 1 + }) + ).toThrow(`Codex session listing exceeded ${exactPathCodeUnits - 1} path code units`) + }) + + it('stops an incremental scan at capacity and reports it as a failed directory', async () => { + const root = await makeRoot() + await writeFile(join(root, 'a.jsonl'), '{}') + await writeFile(join(root, 'b.jsonl'), '{}') + const onDirectoryError = vi.fn() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const files = await collect( + listCodexSessionJsonlFilesIncrementally( + root, + { batchSize: 1, limits: { maxFiles: 1 }, yieldMs: 0 }, + onDirectoryError + ) + ) + + expect(files).toHaveLength(1) + expect(onDirectoryError).toHaveBeenCalledWith( + root, + expect.any(CodexSessionListingCapacityError) + ) + expect(warnSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts index 8b0a8dd6f2e..cfdf1459001 100644 --- a/src/main/codex/codex-session-file-listing.ts +++ b/src/main/codex/codex-session-file-listing.ts @@ -1,10 +1,25 @@ -import { readdirSync } from 'node:fs' +import { opendirSync } from 'node:fs' import { opendir } from 'node:fs/promises' import { join } from 'node:path' +import { + CodexSessionListingBudget, + CodexSessionListingCapacityError, + type CodexSessionListingLimits +} from './codex-session-listing-budget' + +export { + CODEX_SESSION_LISTING_MAX_DEPTH, + CODEX_SESSION_LISTING_MAX_ENTRIES, + CODEX_SESSION_LISTING_MAX_FILES, + CODEX_SESSION_LISTING_MAX_PATH_CODE_UNITS, + CodexSessionListingCapacityError +} from './codex-session-listing-budget' export type CodexSessionBridgeIncrementalOptions = { /** Directory entries to process before yielding back to the event loop. */ batchSize?: number + /** Optional lower limits for tests or constrained callers. */ + limits?: Partial /** Delay after each processed batch; zero still yields on a timer turn. */ yieldMs?: number } @@ -19,32 +34,59 @@ const INCREMENTAL_BRIDGE_YIELD_MS = 10 * that run outside the CLI launch path. */ export function listCodexSessionJsonlFiles(rootPath: string): string[] { + return listCodexSessionJsonlFilesWithinLimits(rootPath) +} + +export function listCodexSessionJsonlFilesWithinLimits( + rootPath: string, + limits: Partial = {} +): string[] { + const budget = new CodexSessionListingBudget(limits) + budget.claimDepth(0) + budget.claimPath(rootPath) const files: string[] = [] - try { - for (const entry of readdirSync(rootPath, { withFileTypes: true })) { - const childPath = join(rootPath, entry.name) - if (entry.isDirectory()) { - appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) - continue + const pendingDirectories = [{ depth: 0, path: rootPath }] + + while (pendingDirectories.length > 0) { + const current = pendingDirectories.pop()! + let directory: ReturnType + try { + directory = opendirSync(current.path) + } catch (error) { + warnAboutCodexSessionListingError(error) + continue + } + try { + for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) { + budget.claimEntry() + const childPath = join(current.path, entry.name) + budget.claimPath(childPath) + if (entry.isDirectory()) { + const depth = current.depth + 1 + budget.claimDepth(depth) + pendingDirectories.push({ depth, path: childPath }) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + budget.claimFile() + files.push(childPath) + } } - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(childPath) + } catch (error) { + if (error instanceof CodexSessionListingCapacityError) { + throw error } + warnAboutCodexSessionListingError(error) + } finally { + closeCodexSessionDirectory(directory) } - } catch (error) { - console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) } return files.sort() } -/** - * Appends session paths without spreading large arrays into a single call. - */ -function appendSessionFilePaths(target: string[], source: readonly string[]): void { - // Why: existing Codex homes can accumulate enough nested sessions to exceed - // V8's argument limit if child arrays are spread into push(). - for (const filePath of source) { - target.push(filePath) +function closeCodexSessionDirectory(directory: ReturnType): void { + try { + directory.closeSync() + } catch { + // The OS may have already closed a failed directory stream. } } @@ -89,21 +131,26 @@ async function* listCodexSessionFilesIncrementally( ): AsyncGenerator { const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) - const pendingDirectories = [rootPath] + const budget = new CodexSessionListingBudget(options.limits) + budget.claimDepth(0) + budget.claimPath(rootPath) + const pendingDirectories = [{ depth: 0, path: rootPath }] let entriesSinceYield = 0 while (pendingDirectories.length > 0) { - const currentDirectory = pendingDirectories.pop() - if (!currentDirectory) { - continue - } + const currentDirectory = pendingDirectories.pop()! try { - const directory = await opendir(currentDirectory) + const directory = await opendir(currentDirectory.path) for await (const entry of directory) { - const childPath = join(currentDirectory, entry.name) + budget.claimEntry() + const childPath = join(currentDirectory.path, entry.name) + budget.claimPath(childPath) if (entry.isDirectory()) { - pendingDirectories.push(childPath) + const depth = currentDirectory.depth + 1 + budget.claimDepth(depth) + pendingDirectories.push({ depth, path: childPath }) } else if (entry.isFile() && isSessionFile(entry.name)) { + budget.claimFile() yield childPath } entriesSinceYield += 1 @@ -113,12 +160,19 @@ async function* listCodexSessionFilesIncrementally( } } } catch (error) { - await onDirectoryError?.(currentDirectory, error) - console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) + await onDirectoryError?.(currentDirectory.path, error) + warnAboutCodexSessionListingError(error) + if (error instanceof CodexSessionListingCapacityError) { + return + } } } } +function warnAboutCodexSessionListingError(error: unknown): void { + console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) +} + /** * Defers incremental bridge work to a later timer turn. */ diff --git a/src/main/codex/codex-session-index-heal-jsonl.test.ts b/src/main/codex/codex-session-index-heal-jsonl.test.ts new file mode 100644 index 00000000000..4a9729a335e --- /dev/null +++ b/src/main/codex/codex-session-index-heal-jsonl.test.ts @@ -0,0 +1,45 @@ +import { appendFileSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES, + readCodexSessionIndexHealJsonlRecords +} from './codex-session-index-heal-jsonl' + +describe('Codex session index heal JSONL reader', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + function makePath(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-heal-jsonl-')) + roots.push(root) + return join(root, 'ledger.jsonl') + } + + it('accepts a valid record exactly at the per-line byte limit', () => { + const path = makePath() + const prefix = '{"padding":"' + const suffix = '"}' + const padding = 'x'.repeat( + MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES - prefix.length - suffix.length + ) + writeFileSync(path, `${prefix}${padding}${suffix}\n`) + + expect([...readCodexSessionIndexHealJsonlRecords(path)]).toEqual([{ padding }]) + }) + + it('skips an oversized sparse line and resumes at the next record', () => { + const path = makePath() + writeFileSync(path, '{"ignored":"') + truncateSync(path, MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES + 8 * 1024 * 1024) + appendFileSync(path, '\n{"kept":true}\n') + + expect([...readCodexSessionIndexHealJsonlRecords(path)]).toEqual([{ kept: true }]) + }) +}) diff --git a/src/main/codex/codex-session-index-heal-jsonl.ts b/src/main/codex/codex-session-index-heal-jsonl.ts new file mode 100644 index 00000000000..cb67e9a8d80 --- /dev/null +++ b/src/main/codex/codex-session-index-heal-jsonl.ts @@ -0,0 +1,102 @@ +import { closeSync, openSync, readSync } from 'node:fs' + +export const MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES = 1024 * 1024 +const HEAL_JSONL_READ_CHUNK_BYTES = 64 * 1024 + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +function parseJsonlRecord(raw: string): Record | null { + if (!raw.trim()) { + return null + } + try { + const parsed: unknown = JSON.parse(raw) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +export function* readCodexSessionIndexHealJsonlRecords( + filePath: string +): Generator> { + let descriptor: number + try { + descriptor = openSync(filePath, 'r') + } catch (error) { + if (isNotFoundError(error)) { + return + } + throw error + } + + const readBuffer = Buffer.allocUnsafe(HEAL_JSONL_READ_CHUNK_BYTES) + let lineParts: Buffer[] = [] + let lineBytes = 0 + let skippingOversizedLine = false + + const resetLine = (): void => { + lineParts = [] + lineBytes = 0 + skippingOversizedLine = false + } + const appendSegment = (start: number, end: number): void => { + if (skippingOversizedLine || start === end) { + return + } + const nextBytes = lineBytes + end - start + if (nextBytes > MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES) { + // Why: a corrupt line may span an arbitrarily large sparse file; discard until its newline. + lineParts = [] + lineBytes = 0 + skippingOversizedLine = true + return + } + lineParts.push(Buffer.from(readBuffer.subarray(start, end))) + lineBytes = nextBytes + } + const takeRecord = (): Record | null => { + const raw = + lineParts.length === 1 + ? lineParts[0].toString('utf8') + : Buffer.concat(lineParts, lineBytes).toString('utf8') + return parseJsonlRecord(raw) + } + + try { + while (true) { + const bytesRead = readSync(descriptor, readBuffer, 0, readBuffer.length, null) + if (bytesRead === 0) { + break + } + let start = 0 + for (let index = 0; index < bytesRead; index += 1) { + if (readBuffer[index] !== 0x0a) { + continue + } + appendSegment(start, index) + if (!skippingOversizedLine) { + const record = takeRecord() + if (record) { + yield record + } + } + resetLine() + start = index + 1 + } + appendSegment(start, bytesRead) + } + if (!skippingOversizedLine && lineBytes > 0) { + const record = takeRecord() + if (record) { + yield record + } + } + } finally { + closeSync(descriptor) + } +} diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index 4efc124316e..d0f7616382f 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -1,10 +1,17 @@ -import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs' +import { appendFileSync, mkdirSync, statSync } from 'node:fs' import { dirname } from 'node:path' import { isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { + MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES, + readCodexSessionIndexHealJsonlRecords +} from './codex-session-index-heal-jsonl' + +export { MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES } // State files for the session index heal: which backfilled rollouts exist // (the backfill audit ledger), which thread ids this pass already processed @@ -14,6 +21,22 @@ import { writeFileAtomically } from '../codex-accounts/fs-utils' // Bump to re-drive the heal for every host after a semantics change; already // processed thread ids are re-read because ledger lines are version-scoped. export const CODEX_SESSION_INDEX_HEAL_VERSION = 3 +export const MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS = 100_000 +export const MAX_CODEX_SESSION_INDEX_HEAL_MARKER_FILE_BYTES = 64 * 1024 + +const MAX_AUDIT_TARGET_LENGTH = 64 * 1024 +const MAX_AUDIT_RECORD_ID_LENGTH = 128 +const MAX_ROLLOUT_STAMP_LENGTH = 512 +const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export class CodexSessionIndexHealCapacityError extends Error { + constructor(kind: 'pending' | 'processed') { + super( + `Codex session index heal ${kind} state exceeds ${MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS} entries` + ) + this.name = 'CodexSessionIndexHealCapacityError' + } +} // Why: an unsupported CLI stays unsupported until upgraded; re-probing once a // day is enough to notice an upgrade without a per-startup spawn. @@ -53,11 +76,11 @@ export type HealMarkerSummary = { export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): PendingHealThread[] { const processed = readProcessedHealThreads(paths) const pendingByThreadId = new Map() - for (const line of readJsonlLines(paths.auditLogPath, true)) { + for (const line of readCodexSessionIndexHealJsonlRecords(paths.auditLogPath)) { if (line.action !== 'hardlink' && line.action !== 'copy' && line.action !== 'existing') { continue } - if (typeof line.target !== 'string') { + if (typeof line.target !== 'string' || line.target.length > MAX_AUDIT_TARGET_LENGTH) { continue } // Why: the append-only audit can contain runs for several custom Codex @@ -66,10 +89,13 @@ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): Pe continue } const match = CODEX_ROLLOUT_THREAD_ID_PATTERN.exec(lastPathSegment(line.target)) - if (!match) { + if (!match || match[1].length > MAX_ROLLOUT_STAMP_LENGTH) { continue } const threadId = match[2].toLowerCase() + if (typeof line.recordId === 'string' && line.recordId.length > MAX_AUDIT_RECORD_ID_LENGTH) { + continue + } const auditRecordId = typeof line.recordId === 'string' ? line.recordId : null if ( processed.healedThreadIds.has(threadId) || @@ -82,6 +108,12 @@ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): Pe pendingByThreadId.delete(threadId) continue } + if ( + !pendingByThreadId.has(threadId) && + pendingByThreadId.size >= MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS + ) { + throw new CodexSessionIndexHealCapacityError('pending') + } pendingByThreadId.set(threadId, { threadId, rolloutStamp: match[1], auditRecordId }) } return [...pendingByThreadId.values()].sort((left, right) => @@ -102,23 +134,49 @@ function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { const missingAuditRecords = new Set() const legacyMissingThreadIds = new Set() const expectedRoot = normalizeRuntimePathForComparison(paths.systemSessionsRoot) - for (const line of readJsonlLines(paths.healLedgerPath)) { - if ( - line.v === CODEX_SESSION_INDEX_HEAL_VERSION && - typeof line.threadId === 'string' && - typeof line.systemSessionsRoot === 'string' && - (line.outcome === 'healed' || line.outcome === 'missing') && - normalizeRuntimePathForComparison(line.systemSessionsRoot) === expectedRoot - ) { - const threadId = line.threadId.toLowerCase() - if (line.outcome === 'healed') { - healedThreadIds.add(threadId) - } else if (typeof line.auditRecordId === 'string') { - missingAuditRecords.add(`${threadId}\0${line.auditRecordId}`) - } else { - legacyMissingThreadIds.add(threadId) + let trackedEntries = 0 + const addTrackedEntry = (entries: Set, key: string): void => { + if (entries.has(key)) { + return + } + if (trackedEntries >= MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS) { + throw new CodexSessionIndexHealCapacityError('processed') + } + entries.add(key) + trackedEntries += 1 + } + try { + for (const line of readCodexSessionIndexHealJsonlRecords(paths.healLedgerPath)) { + if ( + line.v === CODEX_SESSION_INDEX_HEAL_VERSION && + typeof line.threadId === 'string' && + CODEX_THREAD_ID_PATTERN.test(line.threadId) && + typeof line.systemSessionsRoot === 'string' && + (line.outcome === 'healed' || line.outcome === 'missing') && + normalizeRuntimePathForComparison(line.systemSessionsRoot) === expectedRoot + ) { + const threadId = line.threadId.toLowerCase() + if (line.outcome === 'healed') { + addTrackedEntry(healedThreadIds, threadId) + } else if ( + typeof line.auditRecordId === 'string' && + line.auditRecordId.length <= MAX_AUDIT_RECORD_ID_LENGTH + ) { + addTrackedEntry(missingAuditRecords, `${threadId}\0${line.auditRecordId}`) + } else if (line.auditRecordId === undefined) { + addTrackedEntry(legacyMissingThreadIds, threadId) + } } } + } catch (error) { + if (error instanceof CodexSessionIndexHealCapacityError) { + throw error + } + return { + healedThreadIds: new Set(), + missingAuditRecords: new Set(), + legacyMissingThreadIds: new Set() + } } return { healedThreadIds, missingAuditRecords, legacyMissingThreadIds } } @@ -153,35 +211,6 @@ export function appendHealLedgerRecord( } } -function readJsonlLines(filePath: string, throwOnReadFailure = false): Record[] { - let contents: string - try { - contents = readFileSync(filePath, 'utf-8') - } catch (error) { - if (throwOnReadFailure && !isNotFoundError(error)) { - // Why: the audit is the heal work queue. Treating EACCES/EIO as empty - // would write a completion marker that permanently skips every session. - throw error - } - return [] - } - const lines: Record[] = [] - for (const raw of contents.split('\n')) { - if (!raw.trim()) { - continue - } - try { - const parsed: unknown = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - lines.push(parsed as Record) - } - } catch { - // Skip torn/corrupt lines; both ledgers are append-only diagnostics. - } - } - return lines -} - export function readAuditLogSize(auditLogPath: string): number { try { return statSync(auditLogPath).size @@ -202,7 +231,12 @@ export function isHealMarkerCurrent( auditBytes: number ): boolean { try { - const parsed: unknown = JSON.parse(readFileSync(paths.healMarkerPath, 'utf-8')) + const parsed: unknown = JSON.parse( + readNodeFileSyncWithinLimit( + paths.healMarkerPath, + MAX_CODEX_SESSION_INDEX_HEAL_MARKER_FILE_BYTES + ).buffer.toString('utf8') + ) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return false } diff --git a/src/main/codex/codex-session-listing-budget.ts b/src/main/codex/codex-session-listing-budget.ts new file mode 100644 index 00000000000..bcb355eb618 --- /dev/null +++ b/src/main/codex/codex-session-listing-budget.ts @@ -0,0 +1,81 @@ +export const CODEX_SESSION_LISTING_MAX_DEPTH = 32 +export const CODEX_SESSION_LISTING_MAX_ENTRIES = 200_000 +export const CODEX_SESSION_LISTING_MAX_FILES = 100_000 +export const CODEX_SESSION_LISTING_MAX_PATH_CODE_UNITS = 32 * 1024 * 1024 + +export type CodexSessionListingLimits = { + maxDepth: number + maxEntries: number + maxFiles: number + maxPathCodeUnits: number +} + +const DEFAULT_LIMITS: CodexSessionListingLimits = { + maxDepth: CODEX_SESSION_LISTING_MAX_DEPTH, + maxEntries: CODEX_SESSION_LISTING_MAX_ENTRIES, + maxFiles: CODEX_SESSION_LISTING_MAX_FILES, + maxPathCodeUnits: CODEX_SESSION_LISTING_MAX_PATH_CODE_UNITS +} + +export type CodexSessionListingCapacityResource = 'depth' | 'entries' | 'files' | 'path code units' + +export class CodexSessionListingCapacityError extends Error { + constructor( + readonly resource: CodexSessionListingCapacityResource, + readonly observed: number, + readonly limit: number + ) { + super(`Codex session listing exceeded ${limit} ${resource}`) + this.name = 'CodexSessionListingCapacityError' + } +} + +export class CodexSessionListingBudget { + readonly limits: CodexSessionListingLimits + private entries = 0 + private files = 0 + private pathCodeUnits = 0 + + constructor(limits: Partial = {}) { + this.limits = { ...DEFAULT_LIMITS, ...limits } + for (const [name, value] of Object.entries(this.limits)) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + } + } + + claimDepth(depth: number): void { + if (depth > this.limits.maxDepth) { + throw new CodexSessionListingCapacityError('depth', depth, this.limits.maxDepth) + } + } + + claimEntry(): void { + this.entries += 1 + if (this.entries > this.limits.maxEntries) { + throw new CodexSessionListingCapacityError('entries', this.entries, this.limits.maxEntries) + } + } + + claimFile(): void { + this.files += 1 + if (this.files > this.limits.maxFiles) { + throw new CodexSessionListingCapacityError('files', this.files, this.limits.maxFiles) + } + } + + claimPath(path: string): void { + this.pathCodeUnits += path.length + if ( + !Number.isSafeInteger(this.pathCodeUnits) || + this.pathCodeUnits > this.limits.maxPathCodeUnits + ) { + throw new CodexSessionListingCapacityError( + 'path code units', + this.pathCodeUnits, + this.limits.maxPathCodeUnits + ) + } + } +} diff --git a/src/main/codex/codex-trust-config-rollback.test.ts b/src/main/codex/codex-trust-config-rollback.test.ts index 0eae14ff986..0321cfb059b 100644 --- a/src/main/codex/codex-trust-config-rollback.test.ts +++ b/src/main/codex/codex-trust-config-rollback.test.ts @@ -8,10 +8,13 @@ import { rmSync, statSync, symlinkSync, + truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { NodeFileReadTooLargeError } from '../../shared/memory-safety/node-bounded-file-reader' +import { MAX_AGENT_STATE_FILE_BYTES } from '../agent-state-file-reader' import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' const roots: string[] = [] @@ -29,6 +32,15 @@ function tempConfigPath(): string { } describe('Codex trust config rollback', () => { + it('rejects a sparse config above 4 MiB without changing it', () => { + const configPath = tempConfigPath() + writeFileSync(configPath, '') + truncateSync(configPath, MAX_AGENT_STATE_FILE_BYTES + 1) + + expect(() => captureCodexTrustConfig(configPath)).toThrow(NodeFileReadTooLargeError) + expect(statSync(configPath).size).toBe(MAX_AGENT_STATE_FILE_BYTES + 1) + }) + it('treats a missing config as absent and tolerates it remaining absent', () => { const configPath = tempConfigPath() const snapshot = captureCodexTrustConfig(configPath) diff --git a/src/main/codex/codex-trust-config-rollback.ts b/src/main/codex/codex-trust-config-rollback.ts index 0642b834483..98a91f896fc 100644 --- a/src/main/codex/codex-trust-config-rollback.ts +++ b/src/main/codex/codex-trust-config-rollback.ts @@ -1,10 +1,6 @@ import { chmodSync, - closeSync, - fstatSync, lstatSync, - openSync, - readFileSync, readlinkSync, realpathSync, unlinkSync, @@ -12,6 +8,8 @@ import { } from 'node:fs' import { randomUUID } from 'node:crypto' import { dirname, resolve } from 'node:path' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { MAX_AGENT_STATE_FILE_BYTES } from '../agent-state-file-reader' import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' export type CodexTrustConfigSnapshot = @@ -43,27 +41,22 @@ function resolveConfigRestorePath(tomlPath: string): string { export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnapshot { const restorePath = resolveConfigRestorePath(tomlPath) - let descriptor: number try { - descriptor = openSync(restorePath, 'r') + // Why: contents and mode come from one descriptor, so a path replacement + // cannot pair one file's bytes with another file's permissions. + const { buffer, stats } = readNodeFileSyncWithinLimit(restorePath, MAX_AGENT_STATE_FILE_BYTES) + return { + existed: true, + contents: buffer, + mode: stats.mode, + restorePath + } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return restorePath === tomlPath ? { existed: false } : { existed: false, restorePath } } throw error } - try { - // Why: read and stat the same open file so replacement between two path - // lookups cannot pair one file's contents with another file's mode. - return { - existed: true, - contents: readFileSync(descriptor), - mode: fstatSync(descriptor).mode, - restorePath - } - } finally { - closeSync(descriptor) - } } export function restoreCodexTrustConfig( @@ -82,7 +75,11 @@ export function restoreCodexTrustConfig( } const { restorePath } = snapshot try { - if (readFileSync(restorePath).equals(snapshot.contents)) { + if ( + readNodeFileSyncWithinLimit(restorePath, MAX_AGENT_STATE_FILE_BYTES).buffer.equals( + snapshot.contents + ) + ) { // Why: the RPC may change permissions without changing bytes; rollback // restores the complete captured file state, not only its contents. chmodSync(restorePath, snapshot.mode) diff --git a/src/main/codex/codex-wsl-hook-install-plan.test.ts b/src/main/codex/codex-wsl-hook-install-plan.test.ts index 83ab0141a34..89f47334d90 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.test.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.test.ts @@ -6,7 +6,12 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { _internals, createCodexWslRuntimeHookInstallPlan } from './codex-wsl-hook-install-plan' +import { + _internals, + createCodexWslRuntimeHookInstallPlan, + WSL_CANONICAL_PATH_CACHE_MAX_ENTRIES, + WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES +} from './codex-wsl-hook-install-plan' const originalPlatform = process.platform @@ -125,6 +130,17 @@ describe('canonicalizeWslLinuxPath', () => { expect(execFileMock).toHaveBeenCalledTimes(2) }) + it('clears in-flight ownership when spawning wsl.exe throws synchronously', () => { + setPlatform('win32') + execFileMock.mockImplementationOnce(() => { + throw new Error('invalid WSL argv') + }) + + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull() + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull() + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + it('keeps the last known-good cache when revalidation later fails', () => { setPlatform('win32') _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias') @@ -208,4 +224,48 @@ describe('canonicalizeWslLinuxPath', () => { expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull() }) + + it('LRU-evicts canonical identities beyond the entry cap', () => { + setPlatform('win32') + for (let index = 0; index <= WSL_CANONICAL_PATH_CACHE_MAX_ENTRIES; index += 1) { + const logicalPath = `/home/runtime-${index}` + _internals.canonicalizeWslLinuxPath('Ubuntu', logicalPath) + const callback = execFileMock.mock.calls[index][3] as ( + error: Error | null, + stdout: string + ) => void + callback(null, `/canonical/runtime-${index}\n`) + } + + expect(_internals.getWslCanonicalPathCacheSize()).toBe(WSL_CANONICAL_PATH_CACHE_MAX_ENTRIES) + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/runtime-0')).toBeNull() + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/runtime-1')).toBe( + '/canonical/runtime-1' + ) + }) + + it('does not retain a canonical identity larger than the aggregate byte budget', () => { + setPlatform('win32') + _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/oversized') + const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void + callback(null, `/${'x'.repeat(WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES)}\n`) + + expect(_internals.getWslCanonicalPathCacheSize()).toBe(0) + expect(_internals.getWslCanonicalPathCacheBytes()).toBe(0) + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/oversized')).toBeNull() + }) + + it('retains a canonical identity at the exact aggregate byte budget', () => { + setPlatform('win32') + const logicalPath = '/home/exact-limit' + const keyBytes = Buffer.byteLength(`Ubuntu\0${logicalPath}`, 'utf8') + const canonicalPath = `/${'x'.repeat(WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES - keyBytes - 1)}` + _internals.canonicalizeWslLinuxPath('Ubuntu', logicalPath) + const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void + callback(null, canonicalPath) + + expect(_internals.getWslCanonicalPathCacheSize()).toBe(1) + expect(_internals.getWslCanonicalPathCacheBytes()).toBe(WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES) + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', logicalPath)).toBe(canonicalPath) + }) }) diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index 4db2bdea93f..31c92729771 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -48,17 +48,81 @@ function toDefaultWslLinuxPath(windowsPath: string): string { const WSL_CANONICALIZE_TIMEOUT_MS = 5000 const WSL_PATH_MISSING_OUTPUT = '__ORCA_WSL_PATH_MISSING__' +export const WSL_CANONICAL_PATH_CACHE_MAX_ENTRIES = 256 +export const WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES = 2 * 1024 * 1024 // Why: `readlink -f` over wsl.exe stalls up to the timeout on a cold or wedged // distro. Running it synchronously on the Electron main process froze the UI on // every Codex WSL launch, so resolve it off-thread and cache the latest result. -const canonicalWslPathCache = new Map() +const canonicalWslPathCache = new Map() +let canonicalWslPathCacheBytes = 0 const inFlightWslCanonicalizations = new Map>() function wslCanonicalizeCacheKey(distro: string, linuxPath: string): string { return `${distro}\x00${linuxPath}` } +function getCachedWslCanonicalPath(key: string): string | null { + const cached = canonicalWslPathCache.get(key) + if (!cached) { + return null + } + canonicalWslPathCache.delete(key) + canonicalWslPathCache.set(key, cached) + return cached.canonicalPath +} + +function deleteCachedWslCanonicalPath(key: string): void { + const cached = canonicalWslPathCache.get(key) + if (!cached) { + return + } + canonicalWslPathCache.delete(key) + canonicalWslPathCacheBytes -= cached.retainedBytes +} + +function cacheWslCanonicalPath(key: string, canonicalPath: string): void { + deleteCachedWslCanonicalPath(key) + const retainedBytes = Buffer.byteLength(key, 'utf8') + Buffer.byteLength(canonicalPath, 'utf8') + if (retainedBytes > WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES) { + return + } + while ( + canonicalWslPathCache.size >= WSL_CANONICAL_PATH_CACHE_MAX_ENTRIES || + canonicalWslPathCacheBytes + retainedBytes > WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES + ) { + const oldestKey = canonicalWslPathCache.keys().next().value as string | undefined + if (oldestKey === undefined) { + break + } + deleteCachedWslCanonicalPath(oldestKey) + } + canonicalWslPathCache.set(key, { canonicalPath, retainedBytes }) + canonicalWslPathCacheBytes += retainedBytes +} + +function settleWslCanonicalization(key: string, settlement: WslCanonicalPathSettlement): void { + if (settlement.status === 'resolved') { + cacheWslCanonicalPath(key, settlement.canonicalPath) + } else if (settlement.status === 'missing') { + // Why: a successful directory probe is stronger than a transport error; + // clear the identity so stale trust can be revoked and later rediscovered. + deleteCachedWslCanonicalPath(key) + } + // Why: keep the last known-good cache on timeout/transient WSL failures. + // Dropping it forces the next launch onto the logical `/mnt/...` guess, + // which is wrong under custom automount roots and rewrites trust keys. + const settledListeners = inFlightWslCanonicalizations.get(key) ?? new Set() + inFlightWslCanonicalizations.delete(key) + for (const listener of settledListeners) { + try { + listener(settlement) + } catch (listenerError) { + console.warn('[codex-wsl-hook-path] failed to reconcile canonical path', listenerError) + } + } +} + function scheduleWslLinuxPathCanonicalization( distro: string, linuxPath: string, @@ -102,40 +166,28 @@ function scheduleWslLinuxPathCanonicalization( 'sh', linuxPath ] - execFile( - 'wsl.exe', - args, - { encoding: 'utf-8', timeout: WSL_CANONICALIZE_TIMEOUT_MS, windowsHide: true }, - (error, stdout) => { - const canonicalPath = stdout.trim() - const resolvedPath = !error && canonicalPath.startsWith('/') ? canonicalPath : null - const pathMissing = !error && canonicalPath === WSL_PATH_MISSING_OUTPUT - const settlement: WslCanonicalPathSettlement = resolvedPath - ? { status: 'resolved', canonicalPath: resolvedPath } - : pathMissing - ? { status: 'missing' } - : { status: 'unavailable' } - if (settlement.status === 'resolved') { - canonicalWslPathCache.set(key, canonicalPath) - } else if (settlement.status === 'missing') { - // Why: a successful directory probe is stronger than a transport error; - // clear the identity so stale trust can be revoked and later rediscovered. - canonicalWslPathCache.delete(key) + try { + execFile( + 'wsl.exe', + args, + { encoding: 'utf-8', timeout: WSL_CANONICALIZE_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + const canonicalPath = stdout.trim() + const resolvedPath = !error && canonicalPath.startsWith('/') ? canonicalPath : null + const pathMissing = !error && canonicalPath === WSL_PATH_MISSING_OUTPUT + settleWslCanonicalization( + key, + resolvedPath + ? { status: 'resolved', canonicalPath: resolvedPath } + : pathMissing + ? { status: 'missing' } + : { status: 'unavailable' } + ) } - // Why: keep the last known-good cache on timeout/transient WSL failures. - // Dropping it forces the next launch onto the logical `/mnt/...` guess, - // which is wrong under custom automount roots and rewrites trust keys. - const settledListeners = inFlightWslCanonicalizations.get(key) ?? new Set() - inFlightWslCanonicalizations.delete(key) - for (const listener of settledListeners) { - try { - listener(settlement) - } catch (listenerError) { - console.warn('[codex-wsl-hook-path] failed to reconcile canonical path', listenerError) - } - } - } - ) + ) + } catch { + settleWslCanonicalization(key, { status: 'unavailable' }) + } } function canonicalizeWslLinuxPath( @@ -147,7 +199,7 @@ function canonicalizeWslLinuxPath( if (process.platform !== 'win32') { return linuxPath } - const cached = canonicalWslPathCache.get(wslCanonicalizeCacheKey(distro, linuxPath)) + const cached = getCachedWslCanonicalPath(wslCanonicalizeCacheKey(distro, linuxPath)) // Why: every launch revalidates asynchronously. Returning the cache keeps // launch prep synchronous while settlement repairs or revokes trust in-place. scheduleWslLinuxPathCanonicalization(distro, linuxPath, windowsPath, onSettled) @@ -199,6 +251,13 @@ export const _internals = { canonicalizeWslLinuxPath, resetWslCanonicalPathCache(): void { canonicalWslPathCache.clear() + canonicalWslPathCacheBytes = 0 inFlightWslCanonicalizations.clear() + }, + getWslCanonicalPathCacheSize(): number { + return canonicalWslPathCache.size + }, + getWslCanonicalPathCacheBytes(): number { + return canonicalWslPathCacheBytes } } diff --git a/src/main/codex/codex-wsl-reconciliation-generations.ts b/src/main/codex/codex-wsl-reconciliation-generations.ts new file mode 100644 index 00000000000..17cfab2e2ad --- /dev/null +++ b/src/main/codex/codex-wsl-reconciliation-generations.ts @@ -0,0 +1,76 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export type CodexWslReconciliationGenerationBounds = { + maxEntries: number + maxKeyBytes: number + maxTotalKeyBytes: number +} + +export const DEFAULT_CODEX_WSL_RECONCILIATION_GENERATION_BOUNDS: CodexWslReconciliationGenerationBounds = + { + maxEntries: 256, + maxKeyBytes: 64 * 1024, + maxTotalKeyBytes: 1024 * 1024 + } + +type RetainedGeneration = { + generation: number + keyBytes: number +} + +export class CodexWslReconciliationGenerations { + private readonly generations = new Map() + private nextGeneration = 0 + private retainedKeyBytes = 0 + + constructor( + private readonly bounds: CodexWslReconciliationGenerationBounds = DEFAULT_CODEX_WSL_RECONCILIATION_GENERATION_BOUNDS + ) { + for (const value of Object.values(bounds)) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError('Codex WSL reconciliation bounds must be positive integers') + } + } + } + + advance(key: string): number { + const generation = ++this.nextGeneration + const keyBytes = measureUtf8ByteLength(key, { + stopAfterBytes: this.bounds.maxKeyBytes + }) + this.delete(key) + if (keyBytes.exceededLimit || keyBytes.byteLength > this.bounds.maxTotalKeyBytes) { + return generation + } + while ( + this.generations.size >= this.bounds.maxEntries || + this.retainedKeyBytes + keyBytes.byteLength > this.bounds.maxTotalKeyBytes + ) { + const oldest = this.generations.keys().next().value + if (oldest === undefined) { + return generation + } + this.delete(oldest) + } + this.generations.set(key, { generation, keyBytes: keyBytes.byteLength }) + this.retainedKeyBytes += keyBytes.byteLength + return generation + } + + isCurrent(key: string, generation: number): boolean { + return this.generations.get(key)?.generation === generation + } + + evidence(): { entries: number; keyBytes: number } { + return { entries: this.generations.size, keyBytes: this.retainedKeyBytes } + } + + private delete(key: string): void { + const retained = this.generations.get(key) + if (!retained) { + return + } + this.generations.delete(key) + this.retainedKeyBytes -= retained.keyBytes + } +} diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index a4768730c5b..f1f5c991e68 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -3,7 +3,6 @@ import { existsSync, lstatSync, mkdirSync, - readFileSync, realpathSync, statSync, unlinkSync, @@ -12,6 +11,7 @@ import { import { basename, dirname, join, posix as pathPosix, win32 as pathWin32 } from 'node:path' import { createHash, randomUUID } from 'node:crypto' import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { readAgentStateFileSync } from '../agent-state-file-reader' import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths' import { writeRollingFileBackup } from '../rolling-file-backup' import { @@ -327,7 +327,7 @@ function isCodexEventLabel(value: string): value is CodexEventLabel { // Why: strip a leading BOM (some Windows editors write one) so header regexes anchored at `^[ \t]*\[` still match. function readTomlFile(configPath: string): string { - const raw = readFileSync(configPath, 'utf-8') + const raw = readAgentStateFileSync(configPath) return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw } diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 1c4052cefca..e42bf1620e7 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1,8 +1,9 @@ /* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */ -import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs' +import { existsSync, statSync, unlinkSync } from 'node:fs' import { join, win32 as pathWin32 } from 'node:path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { readAgentStateFileSync } from '../agent-state-file-reader' import { buildManagedCommandHook, createManagedCommandMatcher, @@ -11,6 +12,7 @@ import { hookDefinitionHasManagedCommand, MANAGED_HOOK_TIMEOUT_SECONDS, readHooksJson, + readHooksJsonRawForGenerationCheck, readHooksJsonWithRaw, removeManagedCommands, wrapPosixHookCommand, @@ -81,6 +83,7 @@ import { } from './codex-managed-trust-reconciliation' import type { CodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-rebase' +import { CodexWslReconciliationGenerations } from './codex-wsl-reconciliation-generations' // Why: Pre/PostToolUse feed the live in-flight-tool readout; PermissionRequest exits with no decision so Codex still shows its approval UI while Orca flips the pane to waiting. const CODEX_EVENTS = [ @@ -541,7 +544,7 @@ function applyMirroredRuntimeUserHookTrustStates( return } - const existing = readFileSync(tomlPath, 'utf-8') + const existing = readAgentStateFileSync(tomlPath) let updated = existing for (const { entry, enabled } of entries) { const headerKeyPattern = buildHookTrustHeaderKeyPattern(computeTrustKey(entry)) @@ -645,7 +648,7 @@ function cleanupLegacySystemManagedHooks(): void { afterHooks: nextHooks, writeHooks: () => { if ( - readFileSync(legacyConfigPath, 'utf-8') !== previousRaw || + readHooksJsonRawForGenerationCheck(legacyConfigPath) !== previousRaw || resolveHooksJsonWritePath(legacyConfigPath) !== hooksWritePath ) { // Why: the pre-mutation RPC may overlap a user save; downgrade must @@ -691,7 +694,7 @@ function cleanupLegacyCodexProfileHooks(): void { return } - const existing = readFileSync(profilePath, 'utf-8') + const existing = readAgentStateFileSync(profilePath) const next = stripLegacyManagedProfileBlock(existing) if (next === existing) { return @@ -1021,16 +1024,14 @@ function getWslReconciliationKey(runtimeHomePath: string): string { } export class CodexHookService { - private readonly wslReconciliationGeneration = new Map() + private readonly wslReconciliationGenerations = new CodexWslReconciliationGenerations() private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number { if (!runtimeHomePath) { return 0 } const key = getWslReconciliationKey(runtimeHomePath) - const generation = (this.wslReconciliationGeneration.get(key) ?? 0) + 1 - this.wslReconciliationGeneration.set(key, generation) - return generation + return this.wslReconciliationGenerations.advance(key) } installForRuntimeHome( @@ -1058,7 +1059,7 @@ export class CodexHookService { : null const action = getWslHookReconciliationAction({ settlement, - isCurrentGeneration: this.wslReconciliationGeneration.get(key) === generation, + isCurrentGeneration: this.wslReconciliationGenerations.isCurrent(key, generation), installedTrustConfigPath, resolvedTrustConfigPath: resolvedPlan?.trustConfigPath ?? null, installSucceeded diff --git a/src/main/computer/macos-computer-use-permission-status.test.ts b/src/main/computer/macos-computer-use-permission-status.test.ts index cfa511b11ce..a0da6194ed3 100644 --- a/src/main/computer/macos-computer-use-permission-status.test.ts +++ b/src/main/computer/macos-computer-use-permission-status.test.ts @@ -1,12 +1,17 @@ import { execFileSync, spawn, spawnSync } from 'node:child_process' -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { mkdtemp, rm, stat } from 'node:fs/promises' import { join } from 'node:path' +import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as nodeBoundedFileReader from '../../shared/memory-safety/node-bounded-file-reader' const resolveHelperAppPathMock = vi.hoisted(() => vi.fn()) const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn()) const permissionStatusTempDir = '/tmp/orca-computer-use-permissions-test' const permissionStatusPath = join(permissionStatusTempDir, 'status.json') +const { readNodeFileWithinLimitMock } = vi.hoisted(() => ({ + readNodeFileWithinLimitMock: vi.fn() +})) vi.mock('child_process', () => ({ execFileSync: vi.fn(), @@ -30,11 +35,15 @@ vi.mock('child_process', () => ({ vi.mock('fs/promises', () => ({ mkdtemp: vi.fn(), - readFile: vi.fn(), rm: vi.fn(), stat: vi.fn() })) +vi.mock('../../shared/memory-safety/node-bounded-file-reader', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readNodeFileWithinLimit: readNodeFileWithinLimitMock } +}) + vi.mock('./macos-native-provider-paths', () => ({ resolveMacOSComputerUseAppPath: resolveHelperAppPathMock, resolveMacOSComputerUseExecutablePath: resolveHelperExecutablePathMock @@ -48,7 +57,7 @@ describe('getComputerUsePermissionStatus', () => { vi.mocked(spawnSync).mockClear() vi.mocked(execFileSync).mockReset() vi.mocked(mkdtemp).mockReset() - vi.mocked(readFile).mockReset() + readNodeFileWithinLimitMock.mockReset() vi.mocked(rm).mockReset() vi.mocked(stat).mockReset() resolveHelperAppPathMock.mockReset() @@ -162,6 +171,56 @@ describe('getComputerUsePermissionStatus', () => { }) }) + it('kills the helper and rejects oversized launch output', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter & { setEncoding: ReturnType } + stderr: EventEmitter & { setEncoding: ReturnType } + kill: ReturnType + } + child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + child.kill = vi.fn() + vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ReturnType) + + const statusPromise = getComputerUsePermissionStatus() + await Promise.resolve() + child.stdout.emit('data', 'x'.repeat(64 * 1024 + 1)) + + await expect(statusPromise).rejects.toMatchObject({ + name: 'RuntimeClientError', + code: 'accessibility_error', + message: 'Permission helper returned too much launch output' + }) + expect(child.kill).toHaveBeenCalledTimes(1) + }) + + it('preserves helper diagnostics delivered as 50,000 one-byte fragments', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter & { setEncoding: ReturnType } + stderr: EventEmitter & { setEncoding: ReturnType } + kill: ReturnType + } + child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + child.kill = vi.fn() + vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ReturnType) + const statusPromise = getComputerUsePermissionStatus() + await Promise.resolve() + + for (let index = 0; index < 50_000; index += 1) { + child.stderr.emit('data', ' ') + } + child.stderr.emit('data', 'permission denied') + child.emit('close', 1) + + await expect(statusPromise).rejects.toMatchObject({ + code: 'accessibility_error', + message: 'Could not check permissions: permission denied' + }) + }) + it('reads permission status through the helper app identity', async () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}') @@ -187,13 +246,26 @@ describe('getComputerUsePermissionStatus', () => { { stdio: ['ignore', 'pipe', 'pipe'] } ) expect(spawnSync).not.toHaveBeenCalled() - expect(readFile).toHaveBeenCalledWith(permissionStatusPath, 'utf8') + expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith(permissionStatusPath, 64 * 1024) expect(rm).toHaveBeenCalledWith(permissionStatusTempDir, { recursive: true, force: true }) }) + it('maps an oversized permission status file to a stable runtime error', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + readNodeFileWithinLimitMock.mockRejectedValueOnce( + new nodeBoundedFileReader.NodeFileReadTooLargeError(64 * 1024 + 1, 64 * 1024) + ) + + await expect(getComputerUsePermissionStatus()).rejects.toMatchObject({ + name: 'RuntimeClientError', + code: 'accessibility_error', + message: 'Permission helper returned too much status data' + }) + }) + it('returns unavailable permission status when the helper app is missing on macOS', async () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') resolveHelperAppPathMock.mockReturnValue(null) @@ -213,7 +285,10 @@ describe('getComputerUsePermissionStatus', () => { function mockPermissionStatus(json: string): void { vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) - vi.mocked(readFile).mockResolvedValue(json) + readNodeFileWithinLimitMock.mockResolvedValue({ + buffer: Buffer.from(json), + stats: { size: Buffer.byteLength(json) } + }) } function setPlatform(platform: NodeJS.Platform): void { diff --git a/src/main/computer/macos-computer-use-permission-status.ts b/src/main/computer/macos-computer-use-permission-status.ts index b23c3e9ab96..e68744a50f6 100644 --- a/src/main/computer/macos-computer-use-permission-status.ts +++ b/src/main/computer/macos-computer-use-permission-status.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { mkdtemp, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' @@ -13,8 +13,14 @@ import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths' import { RuntimeClientError } from './runtime-client-error' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' const PERMISSION_STATUS_HELPER_LAUNCH_TIMEOUT_MS = 5_000 +export const PERMISSION_STATUS_MAX_BYTES = 64 * 1024 export function getComputerUsePermissionStatus(): Promise { return getComputerUsePermissionStatusAsync() @@ -86,10 +92,22 @@ async function readPermissionStatusFromHelperApp( for (let attempt = 0; attempt < 50; attempt++) { if (await fileExists(statusPath)) { - const output = await readFile(statusPath, 'utf8') - return JSON.parse(output) as Partial< - Record - > + try { + const output = ( + await readNodeFileWithinLimit(statusPath, PERMISSION_STATUS_MAX_BYTES) + ).buffer.toString('utf8') + return JSON.parse(output) as Partial< + Record + > + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw new RuntimeClientError( + 'accessibility_error', + 'Permission helper returned too much status data' + ) + } + throw error + } } await delay(100) } @@ -108,16 +126,41 @@ function launchPermissionStatusHelper(helperAppPath: string, statusPath: string) stdio: ['ignore', 'pipe', 'pipe'] } ) - let stdout = '' - let stderr = '' + const stdout = new GrowingByteBuffer() + const stderr = new GrowingByteBuffer() + let outputBytes = 0 launch.stdout?.setEncoding('utf8') launch.stderr?.setEncoding('utf8') const onStdoutData = (chunk: string): void => { - stdout += chunk + const bytes = Buffer.from(chunk) + outputBytes += bytes.byteLength + if (outputBytes > PERMISSION_STATUS_MAX_BYTES) { + launch.kill() + settleReject( + new RuntimeClientError( + 'accessibility_error', + 'Permission helper returned too much launch output' + ) + ) + return + } + stdout.append(bytes) } const onStderrData = (chunk: string): void => { - stderr += chunk + const bytes = Buffer.from(chunk) + outputBytes += bytes.byteLength + if (outputBytes > PERMISSION_STATUS_MAX_BYTES) { + launch.kill() + settleReject( + new RuntimeClientError( + 'accessibility_error', + 'Permission helper returned too much launch output' + ) + ) + return + } + stderr.append(bytes) } let settled = false let launchTimeout: ReturnType | null = null @@ -137,6 +180,8 @@ function launchPermissionStatusHelper(helperAppPath: string, statusPath: string) } settled = true removeListeners() + stdout.clear() + stderr.clear() resolve() } const settleReject = (error: Error): void => { @@ -145,6 +190,8 @@ function launchPermissionStatusHelper(helperAppPath: string, statusPath: string) } settled = true removeListeners() + stdout.clear() + stderr.clear() reject(error) } const onError = (): void => { @@ -160,7 +207,8 @@ function launchPermissionStatusHelper(helperAppPath: string, statusPath: string) settleResolve() return } - const detail = stderr.trim() || stdout.trim() || `exit ${status ?? 'unknown'}` + const detail = + stderr.toString().trim() || stdout.toString().trim() || `exit ${status ?? 'unknown'}` settleReject( new RuntimeClientError('accessibility_error', `Could not check permissions: ${detail}`) ) diff --git a/src/main/computer/macos-computer-use-permissions.test.ts b/src/main/computer/macos-computer-use-permissions.test.ts index a8b6442b719..cd3aa10b5fa 100644 --- a/src/main/computer/macos-computer-use-permissions.test.ts +++ b/src/main/computer/macos-computer-use-permissions.test.ts @@ -1,7 +1,8 @@ import { execFileSync, spawn, spawnSync } from 'node:child_process' -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { mkdtemp, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as nodeBoundedFileReader from '../../shared/memory-safety/node-bounded-file-reader' import { openComputerUsePermissions, resetComputerUsePermissions @@ -12,6 +13,9 @@ const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn()) const permissionStatusTempDir = '/tmp/orca-computer-use-permissions-test' const helperAppPath = '/Applications/Orca Computer Use.app' const helperInfoPlistPath = join(helperAppPath, 'Contents', 'Info.plist') +const { readNodeFileWithinLimitMock } = vi.hoisted(() => ({ + readNodeFileWithinLimitMock: vi.fn() +})) vi.mock('child_process', () => ({ execFileSync: vi.fn(), @@ -35,11 +39,15 @@ vi.mock('child_process', () => ({ vi.mock('fs/promises', () => ({ mkdtemp: vi.fn(), - readFile: vi.fn(), rm: vi.fn(), stat: vi.fn() })) +vi.mock('../../shared/memory-safety/node-bounded-file-reader', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readNodeFileWithinLimit: readNodeFileWithinLimitMock } +}) + vi.mock('./macos-native-provider-paths', () => ({ resolveMacOSComputerUseAppPath: resolveHelperAppPathMock, resolveMacOSComputerUseExecutablePath: resolveHelperExecutablePathMock @@ -53,7 +61,7 @@ describe('openComputerUsePermissions', () => { vi.mocked(spawnSync).mockClear() vi.mocked(execFileSync).mockReset() vi.mocked(mkdtemp).mockReset() - vi.mocked(readFile).mockReset() + readNodeFileWithinLimitMock.mockReset() vi.mocked(rm).mockReset() vi.mocked(stat).mockReset() resolveHelperAppPathMock.mockReset() @@ -210,9 +218,13 @@ describe('openComputerUsePermissions', () => { it('resets stale macOS TCC grants for the helper bundle id', async () => { resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') - vi.mocked(readFile) - .mockResolvedValueOnce('{"accessibility":"granted","screenshots":"granted"}') - .mockResolvedValueOnce('{"accessibility":"not-granted","screenshots":"not-granted"}') + readNodeFileWithinLimitMock + .mockResolvedValueOnce( + boundedPermissionStatus('{"accessibility":"granted","screenshots":"granted"}') + ) + .mockResolvedValueOnce( + boundedPermissionStatus('{"accessibility":"not-granted","screenshots":"not-granted"}') + ) vi.mocked(execFileSync).mockReturnValueOnce('com.example.orca.computer-use\n') vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) @@ -246,7 +258,14 @@ describe('openComputerUsePermissions', () => { function mockPermissionStatus(json: string): void { vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) - vi.mocked(readFile).mockResolvedValue(json) + readNodeFileWithinLimitMock.mockResolvedValue(boundedPermissionStatus(json)) +} + +function boundedPermissionStatus(json: string) { + return { + buffer: Buffer.from(json), + stats: { size: Buffer.byteLength(json) } + } } function setPlatform(platform: NodeJS.Platform): void { diff --git a/src/main/computer/macos-native-provider-client.ts b/src/main/computer/macos-native-provider-client.ts index 0b917037685..faf71f955d2 100644 --- a/src/main/computer/macos-native-provider-client.ts +++ b/src/main/computer/macos-native-provider-client.ts @@ -20,7 +20,7 @@ import { import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths' import { attachMacOSNativeProviderSocketListeners, - consumeNativeProviderLines, + MacOSNativeProviderLineBuffer, startMacOSNativeProviderSocket } from './macos-native-provider-transport' import { validateComputerProviderActionParams } from './computer-provider-action-validation' @@ -37,7 +37,7 @@ export class MacOSNativeProviderClient { private socketToken: string | null = null private nextId = 1 private pending = new Map() - private socketBuffer = '' + private readonly socketBuffer = new MacOSNativeProviderLineBuffer() private providerCapabilities: ComputerProviderCapabilities | null = null private socketListenerCleanup: (() => void) | null = null private socketStartGeneration = 0 @@ -70,20 +70,16 @@ export class MacOSNativeProviderClient { this.socketStartPromise = null this.socketStartGeneration++ this.providerCapabilities = null - this.socketBuffer = '' + this.socketBuffer.clear() this.cleanupActiveSocketListeners() if (socket && !socket.destroyed) { const id = this.nextId++ socket.write(`${JSON.stringify({ id, method: 'terminate', params: {}, token })}\n`) socket.end() } - for (const [id, pending] of this.pending) { - clearTimeout(pending.timer) - pending.reject( - new RuntimeClientError('accessibility_error', 'native macOS provider shut down') - ) - this.pending.delete(id) - } + this.rejectPending( + new RuntimeClientError('accessibility_error', 'native macOS provider shut down') + ) this.cleanupSocketDirectory() } private async call(method: NativeMethod, params: unknown): Promise { @@ -199,7 +195,7 @@ export class MacOSNativeProviderClient { this.socketToken = started.socketToken const socket = started.socket socket.setEncoding('utf8') - this.socketBuffer = '' + this.socketBuffer.clear() this.socketListenerCleanup = attachMacOSNativeProviderSocketListeners(socket, { data: (chunk) => this.handleSocketData(socket, chunk), close: () => this.handleSocketClose(socket), @@ -214,10 +210,11 @@ export class MacOSNativeProviderClient { if (this.socket !== socket) { return } - this.socketBuffer += chunk - this.socketBuffer = consumeNativeProviderLines(this.socketBuffer, (line) => - this.handleLine(line) - ) + try { + this.socketBuffer.feed(chunk, (line) => this.handleLine(line)) + } catch (error) { + this.handleTransportError(socket, error instanceof Error ? error : new Error(String(error))) + } } private handleLine(line: string): void { let response: NativeResponse @@ -245,7 +242,7 @@ export class MacOSNativeProviderClient { } this.cleanupActiveSocketListeners() this.socket = null - this.socketBuffer = '' + this.socketBuffer.clear() this.cleanupSocketDirectory() this.rejectPending( new RuntimeClientError('accessibility_error', 'native macOS helper app connection closed') @@ -259,7 +256,7 @@ export class MacOSNativeProviderClient { this.cleanupActiveSocketListeners() // Why: an active transport error makes the helper socket unreliable for the next request. this.socket = null - this.socketBuffer = '' + this.socketBuffer.clear() if (!socket.destroyed) { socket.destroy() } @@ -275,7 +272,7 @@ export class MacOSNativeProviderClient { } this.cleanupActiveSocketListeners() this.socket = null - this.socketBuffer = '' + this.socketBuffer.clear() if (!socket.destroyed) { socket.destroy() } diff --git a/src/main/computer/macos-native-provider-transport.test.ts b/src/main/computer/macos-native-provider-transport.test.ts new file mode 100644 index 00000000000..e3e17b30a72 --- /dev/null +++ b/src/main/computer/macos-native-provider-transport.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MacOSNativeProviderLineBuffer, + MacOSNativeProviderResponseTooLargeError, + consumeNativeProviderLines +} from './macos-native-provider-transport' + +describe('consumeNativeProviderLines', () => { + it('preserves complete lines and returns the partial tail', () => { + const handleLine = vi.fn() + + const remaining = consumeNativeProviderLines('one\ntwo\npartial', handleLine, 16) + + expect(handleLine.mock.calls.map(([line]) => line)).toEqual(['one', 'two']) + expect(remaining).toBe('partial') + }) + + it('rejects a complete oversized response before dispatching it', () => { + const handleLine = vi.fn() + + expect(() => consumeNativeProviderLines('12345\n', handleLine, 4)).toThrow( + MacOSNativeProviderResponseTooLargeError + ) + expect(handleLine).not.toHaveBeenCalled() + }) + + it('rejects a newline-free response once its retained tail crosses the limit', () => { + expect(() => consumeNativeProviderLines('12345', vi.fn(), 4)).toThrow( + 'native macOS provider response exceeded' + ) + }) +}) + +describe('MacOSNativeProviderLineBuffer', () => { + it('parses a response delivered in more than 100,000 one-character fragments', () => { + const buffer = new MacOSNativeProviderLineBuffer() + const handleLine = vi.fn() + const line = JSON.stringify({ id: 1, ok: true, result: 'x'.repeat(100_000) }) + + for (const character of line) { + buffer.feed(character, handleLine) + } + buffer.feed('\n', handleLine) + + expect(handleLine).toHaveBeenCalledWith(line) + }) + + it('clears an oversized fragmented line and accepts a later response', () => { + const buffer = new MacOSNativeProviderLineBuffer(4) + const handleLine = vi.fn() + buffer.feed('12', handleLine) + expect(() => buffer.feed('345', handleLine)).toThrow(MacOSNativeProviderResponseTooLargeError) + + buffer.feed('ok\n', handleLine) + + expect(handleLine).toHaveBeenCalledWith('ok') + }) +}) diff --git a/src/main/computer/macos-native-provider-transport.ts b/src/main/computer/macos-native-provider-transport.ts index 54c4cf773aa..262e6760589 100644 --- a/src/main/computer/macos-native-provider-transport.ts +++ b/src/main/computer/macos-native-provider-transport.ts @@ -6,8 +6,20 @@ import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { connectMacOSProviderSocket } from './macos-native-provider-socket' import { RuntimeClientError } from './runtime-client-error' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' const HELPER_CONNECT_TIMEOUT_MS = 10_000 +export const MACOS_NATIVE_PROVIDER_MAX_RESPONSE_LINE_BYTES = 64 * 1024 * 1024 + +export class MacOSNativeProviderResponseTooLargeError extends Error { + constructor( + readonly observedBytes: number, + readonly maxBytes: number + ) { + super(`native macOS provider response exceeded ${maxBytes} byte line limit`) + this.name = 'MacOSNativeProviderResponseTooLargeError' + } +} export type StartedMacOSProviderSocket = { socket: net.Socket @@ -16,6 +28,40 @@ export type StartedMacOSProviderSocket = { socketToken: string } +export class MacOSNativeProviderLineBuffer { + private readonly pending = new GrowingByteBuffer() + + constructor(private readonly maxLineBytes = MACOS_NATIVE_PROVIDER_MAX_RESPONSE_LINE_BYTES) {} + + feed(chunk: string, handleLine: (line: string) => void): void { + let remaining = chunk + while (remaining.length > 0) { + const newline = remaining.indexOf('\n') + const hasNewline = newline >= 0 + const segment = hasNewline ? remaining.slice(0, newline) : remaining + remaining = hasNewline ? remaining.slice(newline + 1) : '' + const segmentBytes = Buffer.byteLength(segment) + const observedBytes = this.pending.byteLength + segmentBytes + if (observedBytes > this.maxLineBytes) { + this.pending.clear() + throw new MacOSNativeProviderResponseTooLargeError(observedBytes, this.maxLineBytes) + } + this.pending.append(Buffer.from(segment)) + if (!hasNewline) { + return + } + const line = this.pending.takeString('utf8') + if (line.trim()) { + handleLine(line) + } + } + } + + clear(): void { + this.pending.clear() + } +} + export function isMacOS14OrNewer(): boolean { const darwinMajor = Number.parseInt(release().split('.')[0] ?? '', 10) return Number.isFinite(darwinMajor) && darwinMajor >= 23 @@ -47,16 +93,25 @@ export function attachMacOSNativeProviderSocketListeners( export function consumeNativeProviderLines( buffer: string, - handleLine: (line: string) => void + handleLine: (line: string) => void, + maxLineBytes = MACOS_NATIVE_PROVIDER_MAX_RESPONSE_LINE_BYTES ): string { let remaining = buffer while (true) { const newline = remaining.indexOf('\n') if (newline < 0) { + const remainingBytes = Buffer.byteLength(remaining) + if (remainingBytes > maxLineBytes) { + throw new MacOSNativeProviderResponseTooLargeError(remainingBytes, maxLineBytes) + } return remaining } const line = remaining.slice(0, newline) remaining = remaining.slice(newline + 1) + const lineBytes = Buffer.byteLength(line) + if (lineBytes > maxLineBytes) { + throw new MacOSNativeProviderResponseTooLargeError(lineBytes, maxLineBytes) + } if (line.trim()) { handleLine(line) } diff --git a/src/main/daemon/binary-frame.test.ts b/src/main/daemon/binary-frame.test.ts index 81148f18a59..468c34a0dab 100644 --- a/src/main/daemon/binary-frame.test.ts +++ b/src/main/daemon/binary-frame.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { encodeFrame, createFrameParser, FrameType, FRAME_HEADER_SIZE } from './binary-frame' +import { FRAME_MAX_PAYLOAD } from './types' describe('encodeFrame', () => { it('encodes a data frame with correct header', () => { @@ -130,6 +131,22 @@ describe('createFrameParser', () => { expect(onFrame.mock.calls[0][1].length).toBe(0) }) + it('rejects an oversized declared payload from the header alone', () => { + const onFrame = vi.fn() + const parser = createFrameParser(onFrame) + const header = Buffer.alloc(FRAME_HEADER_SIZE) + header[0] = FrameType.Data + header.writeUInt32BE(FRAME_MAX_PAYLOAD + 1, 1) + + expect(() => parser.feed(header)).toThrow( + `Frame payload ${FRAME_MAX_PAYLOAD + 1} exceeds max ${FRAME_MAX_PAYLOAD}` + ) + expect(onFrame).not.toHaveBeenCalled() + + parser.feed(encodeFrame(FrameType.Data, Buffer.from('fresh'))) + expect(onFrame).toHaveBeenCalledOnce() + }) + it('parses different frame types correctly', () => { const onFrame = vi.fn() const parser = createFrameParser(onFrame) diff --git a/src/main/daemon/binary-frame.ts b/src/main/daemon/binary-frame.ts index e8c4fd6ae90..c43a488cbe9 100644 --- a/src/main/daemon/binary-frame.ts +++ b/src/main/daemon/binary-frame.ts @@ -29,6 +29,10 @@ export function createFrameParser( function parse(): void { while (buffer.length >= FRAME_HEADER_SIZE) { const payloadLength = buffer.readUInt32BE(1) + if (payloadLength > FRAME_MAX_PAYLOAD) { + buffer = Buffer.alloc(0) + throw new Error(`Frame payload ${payloadLength} exceeds max ${FRAME_MAX_PAYLOAD}`) + } const totalLength = FRAME_HEADER_SIZE + payloadLength if (buffer.length < totalLength) { diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index a3d6d9f610d..eb9dbb8de90 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' import { DaemonClient } from './client' -import { encodeNdjson } from './ndjson' +import { DAEMON_HANDSHAKE_MAX_LINE_BYTES, encodeNdjson } from './ndjson' import type { HelloMessage, DaemonRequest, DaemonEvent } from './types' import { getDaemonSocketPath } from './daemon-spawner' @@ -310,6 +310,31 @@ describe('DaemonClient', () => { expect(socket.listenerCount('close')).toBe(0) }) + it('rejects a newline-free oversized hello response before the timeout', async () => { + client = new DaemonClient({ socketPath, tokenPath }) + const socket = new EventEmitter() as Socket + socket.write = vi.fn(() => true) as unknown as Socket['write'] + socket.destroy = vi.fn() as unknown as Socket['destroy'] + const sendHello = ( + client as unknown as { + sendHello( + socket: Socket, + token: string, + role: 'control' | 'stream', + timeoutMs: number + ): Promise + } + ).sendHello.bind(client) + + const promise = sendHello(socket, 'test-token-123', 'control', 60_000) + socket.emit('data', Buffer.alloc(DAEMON_HANDSHAKE_MAX_LINE_BYTES + 1, 0x78)) + + await expect(promise).rejects.toThrow('Invalid hello response') + expect(socket.listenerCount('data')).toBe(0) + expect(socket.listenerCount('error')).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + }) + it('rejects when the daemon closes before hello completes', async () => { await startMockDaemon({ closeOnHello: true }) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index d0dd2453471..5f9650ad6cf 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -1,9 +1,18 @@ /* eslint-disable max-lines -- Why: daemon handshake, RPC, stream events, and reconnect cleanup share one socket lifecycle. */ import { connect, type Socket } from 'node:net' -import { readFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import { StringDecoder } from 'node:string_decoder' -import { encodeNdjson, createNdjsonParser } from './ndjson' +import { + DAEMON_HANDSHAKE_MAX_LINE_BYTES, + encodeBoundedNdjson, + encodeNdjson, + createNdjsonParser +} from './ndjson' +import { + DAEMON_CONTROL_SOCKET_MAX_BUFFERED_BYTES, + DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT, + DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT +} from './daemon-admission-limits' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, PROTOCOL_VERSION, @@ -18,10 +27,14 @@ import type { DaemonEvent } from './types' import { addNodePtyRecoveryHint } from './node-pty-error-hints' +import { readDaemonControlFileText } from './daemon-control-file-reader' const CONNECT_TIMEOUT_MS = 5000 const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4 const REQUEST_TIMEOUT_MS = 30000 +export const DAEMON_CLIENT_MAX_PENDING_REQUESTS = DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT +export const DAEMON_CLIENT_MAX_CONTROL_BUFFERED_BYTES = DAEMON_CONTROL_SOCKET_MAX_BUFFERED_BYTES +export const DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES = DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT export type DaemonClientOptions = { socketPath: string @@ -118,7 +131,7 @@ export class DaemonClient { attemptGeneration: number, sharedBudget: boolean ): Promise { - const token = readFileSync(this.tokenPath, 'utf-8').trim() + const token = readDaemonControlFileText(this.tokenPath).trim() const deadlineMs = Date.now() + timeoutMs const remainingMs = (): number => sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs @@ -196,9 +209,18 @@ export class DaemonClient { if (!this.connected || !this.controlSocket) { throw new DaemonProtocolError('Not connected') } + if (this.pendingRequests.size >= DAEMON_CLIENT_MAX_PENDING_REQUESTS) { + throw new DaemonProtocolError('Daemon client pending request limit reached') + } const id = `req-${++this.requestCounter}` const msg = { id, type, ...(payload !== undefined ? { payload } : {}) } + const encoded = encodeBoundedNdjson(msg, DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES + 1) + const socket = this.controlSocket + if (!this.canBufferControlMessage(socket, encoded)) { + socket.destroy(new DaemonProtocolError('Daemon client control buffer limit reached')) + throw new DaemonProtocolError('Daemon client control buffer limit reached') + } return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -212,7 +234,13 @@ export class DaemonClient { timer }) - this.controlSocket!.write(encodeNdjson(msg)) + try { + socket.write(encoded) + } catch (error) { + this.pendingRequests.delete(id) + clearTimeout(timer) + reject(error instanceof Error ? error : new DaemonProtocolError(String(error))) + } }) } @@ -223,7 +251,14 @@ export class DaemonClient { const id = `${NOTIFY_PREFIX}${++this.requestCounter}` const msg = { id, type, ...(payload !== undefined ? { payload } : {}) } - this.controlSocket.write(encodeNdjson(msg)) + const encoded = encodeBoundedNdjson(msg, DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES + 1) + if (!this.canBufferControlMessage(this.controlSocket, encoded)) { + this.controlSocket.destroy( + new DaemonProtocolError('Daemon client control buffer limit reached') + ) + return + } + this.controlSocket.write(encoded) } onEvent(listener: (event: unknown) => void): () => void { @@ -333,7 +368,6 @@ export class DaemonClient { role } - let buffer = '' let settled = false let timer: ReturnType | null = null const cleanup = (): void => { @@ -360,16 +394,12 @@ export class DaemonClient { // Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes. // Decoding each Buffer independently would permanently inject U+FFFD. const decoder = new StringDecoder('utf8') - const onData = (chunk: Buffer): void => { - buffer += decoder.write(chunk) - const newlineIdx = buffer.indexOf('\n') - if (newlineIdx === -1) { - return - } - - const line = buffer.slice(0, newlineIdx) - try { - const response = JSON.parse(line) as HelloResponse + const parser = createNdjsonParser( + (message) => { + if (settled) { + return + } + const response = message as HelloResponse if (response.ok) { const identity = parseDaemonEndpointIdentity(response.daemonIdentity) if ( @@ -385,9 +415,15 @@ export class DaemonClient { new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected')) ) } - } catch { - finish(new DaemonProtocolError('Invalid hello response')) + }, + () => finish(new DaemonProtocolError('Invalid hello response')), + { maxLineBytes: DAEMON_HANDSHAKE_MAX_LINE_BYTES } + ) + const onData = (chunk: Buffer): void => { + if (settled) { + return } + parser.feed(decoder.write(chunk)) } const onError = (error: Error): void => finish(error) const onClose = (): void => @@ -455,6 +491,11 @@ export class DaemonClient { return () => socket.off('data', onData) } + private canBufferControlMessage(socket: Socket, encoded: string): boolean { + const messageBytes = Buffer.byteLength(encoded, 'utf8') + return messageBytes <= DAEMON_CLIENT_MAX_CONTROL_BUFFERED_BYTES - socket.writableLength + } + private handleDisconnect(generation: number): void { if (!this.disconnectArmed || generation !== this.connectionGeneration) { return diff --git a/src/main/daemon/daemon-admission-limits.test.ts b/src/main/daemon/daemon-admission-limits.test.ts new file mode 100644 index 00000000000..daa7c00f914 --- /dev/null +++ b/src/main/daemon/daemon-admission-limits.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { + DAEMON_CLIENT_ID_MAX_BYTES, + DAEMON_PTY_COMMAND_MAX_BYTES, + DAEMON_PTY_CWD_MAX_BYTES, + DAEMON_PTY_ENV_DELETE_MAX_ENTRIES, + DAEMON_PTY_ENV_MAX_BYTES, + DAEMON_PTY_ENV_MAX_ENTRIES, + DAEMON_PTY_ENV_VALUE_MAX_BYTES, + DAEMON_PTY_HISTORY_SEED_MAX_BYTES, + DAEMON_REQUEST_ID_MAX_BYTES, + DAEMON_SESSION_ID_MAX_BYTES, + daemonHelloAdmissionError, + daemonRequestAdmissionError +} from './daemon-admission-limits' +import { MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS } from '../../shared/terminal-size-limits' + +function createRequest(payload: Record): Record { + return { id: 'req-1', type: 'createOrAttach', payload } +} + +describe('daemon admission limits', () => { + it('preserves ordinary cross-platform create fields without rewriting them', () => { + const request = createRequest({ + sessionId: 'repo::C:\\projects\\orca@@12345678', + cols: 120, + rows: 40, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\orca', + command: 'printf "ready"', + env: { PATH: 'C:\\Windows\\System32', ORCA_TERMINAL_HANDLE: 'term_123' }, + envToDelete: ['CODEX_HOME'], + terminalWindowsWslDistro: null, + historySeed: '\u001b[32mrestored\u001b[0m' + }) + + expect(daemonRequestAdmissionError(request)).toBeNull() + expect(request).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\orca', + terminalWindowsWslDistro: null + }) + }) + ) + }) + + it('measures hello and request identifiers by UTF-8 bytes', () => { + const exactClientId = 'é'.repeat(DAEMON_CLIENT_ID_MAX_BYTES / 2) + expect( + daemonHelloAdmissionError({ type: 'hello', role: 'control', clientId: exactClientId }) + ).toBeNull() + expect( + daemonHelloAdmissionError({ + type: 'hello', + role: 'control', + clientId: `${exactClientId}é` + }) + ).toContain(`${DAEMON_CLIENT_ID_MAX_BYTES} bytes`) + + const exactRequestId = 'é'.repeat(DAEMON_REQUEST_ID_MAX_BYTES / 2) + expect(daemonRequestAdmissionError({ id: exactRequestId, type: 'ping' })).toBeNull() + expect(daemonRequestAdmissionError({ id: `${exactRequestId}é`, type: 'ping' })).toContain( + `${DAEMON_REQUEST_ID_MAX_BYTES} bytes` + ) + }) + + it('rejects unbounded retained identifiers and PTY strings', () => { + expect( + daemonRequestAdmissionError({ + id: 'req', + type: 'getCwd', + payload: { sessionId: 's'.repeat(DAEMON_SESSION_ID_MAX_BYTES + 1) } + }) + ).toContain(`${DAEMON_SESSION_ID_MAX_BYTES} bytes`) + expect( + daemonRequestAdmissionError( + createRequest({ + sessionId: 's', + cwd: 'c'.repeat(DAEMON_PTY_CWD_MAX_BYTES + 1) + }) + ) + ).toContain(`${DAEMON_PTY_CWD_MAX_BYTES} bytes`) + expect( + daemonRequestAdmissionError( + createRequest({ + sessionId: 's', + command: 'c'.repeat(DAEMON_PTY_COMMAND_MAX_BYTES + 1) + }) + ) + ).toContain(`${DAEMON_PTY_COMMAND_MAX_BYTES} bytes`) + expect( + daemonRequestAdmissionError( + createRequest({ + sessionId: 's', + historySeed: 'h'.repeat(DAEMON_PTY_HISTORY_SEED_MAX_BYTES + 1) + }) + ) + ).toContain(`${DAEMON_PTY_HISTORY_SEED_MAX_BYTES} bytes`) + }) + + it('caps environment entry counts, individual values, and aggregate bytes', () => { + const tooManyEntries = Object.fromEntries( + Array.from({ length: DAEMON_PTY_ENV_MAX_ENTRIES + 1 }, (_, index) => [`K${index}`, 'v']) + ) + expect( + daemonRequestAdmissionError(createRequest({ sessionId: 's', env: tooManyEntries })) + ).toContain(`${DAEMON_PTY_ENV_MAX_ENTRIES} entries`) + + expect( + daemonRequestAdmissionError( + createRequest({ + sessionId: 's', + env: { VALUE: 'v'.repeat(DAEMON_PTY_ENV_VALUE_MAX_BYTES + 1) } + }) + ) + ).toContain(`${DAEMON_PTY_ENV_VALUE_MAX_BYTES} bytes`) + + const aggregate = Object.fromEntries( + Array.from({ length: 5 }, (_, index) => [ + `K${index}`, + 'v'.repeat(Math.floor(DAEMON_PTY_ENV_MAX_BYTES / 5)) + ]) + ) + expect( + daemonRequestAdmissionError(createRequest({ sessionId: 's', env: aggregate })) + ).toContain(`${DAEMON_PTY_ENV_MAX_BYTES} bytes`) + }) + + it('caps environment deletion lists and rejects malformed retained fields', () => { + expect( + daemonRequestAdmissionError( + createRequest({ + sessionId: 's', + envToDelete: Array.from( + { length: DAEMON_PTY_ENV_DELETE_MAX_ENTRIES + 1 }, + (_, index) => `K${index}` + ) + }) + ) + ).toContain(`${DAEMON_PTY_ENV_DELETE_MAX_ENTRIES} entries`) + expect( + daemonRequestAdmissionError(createRequest({ sessionId: 's', env: ['not-a-record'] })) + ).toBe('createOrAttach payload.env must be a string record') + expect(daemonRequestAdmissionError({ id: 'req', type: 'getCwd', payload: {} })).toBe( + 'getCwd payload.sessionId must be a string' + ) + }) + + it('rejects PTY dimensions that could force pathological native allocations', () => { + expect( + daemonRequestAdmissionError( + createRequest({ sessionId: 's', cols: MAX_TERMINAL_COLS + 1, rows: 24 }) + ) + ).toContain(`1 through ${MAX_TERMINAL_COLS}`) + expect( + daemonRequestAdmissionError({ + id: 'req', + type: 'resize', + payload: { sessionId: 's', cols: 80, rows: MAX_TERMINAL_ROWS + 1 } + }) + ).toContain(`1 through ${MAX_TERMINAL_ROWS}`) + }) +}) diff --git a/src/main/daemon/daemon-admission-limits.ts b/src/main/daemon/daemon-admission-limits.ts new file mode 100644 index 00000000000..4bef52abbab --- /dev/null +++ b/src/main/daemon/daemon-admission-limits.ts @@ -0,0 +1,293 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' +import { terminalSizeAdmissionError } from '../../shared/terminal-size-limits' + +export const DAEMON_MAX_TRANSPORT_SOCKETS = 128 +export const DAEMON_MAX_CONTROL_CLIENTS = 32 +export const DAEMON_MAX_STREAM_ATTACHMENTS = 24 +export const DAEMON_HANDSHAKE_TIMEOUT_MS = 10_000 + +export const DAEMON_MAX_ACTIVE_REQUESTS = 256 +export const DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT = 128 +export const DAEMON_MAX_ACTIVE_REQUEST_BYTES = 32 * 1024 * 1024 +export const DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT = 16 * 1024 * 1024 +export const DAEMON_CONTROL_SOCKET_MAX_BUFFERED_BYTES = 32 * 1024 * 1024 + +export const DAEMON_CLIENT_ID_MAX_BYTES = 1024 +export const DAEMON_REQUEST_ID_MAX_BYTES = 1024 +export const DAEMON_REQUEST_TYPE_MAX_BYTES = 256 +export const DAEMON_SESSION_ID_MAX_BYTES = 4 * 1024 +export const DAEMON_PTY_CWD_MAX_BYTES = 256 * 1024 +export const DAEMON_PTY_COMMAND_MAX_BYTES = 4 * 1024 * 1024 +export const DAEMON_PTY_HISTORY_SEED_MAX_BYTES = 12 * 1024 * 1024 +export const DAEMON_PTY_ENV_MAX_ENTRIES = 4096 +export const DAEMON_PTY_ENV_NAME_MAX_BYTES = 32 * 1024 +export const DAEMON_PTY_ENV_VALUE_MAX_BYTES = 1024 * 1024 +export const DAEMON_PTY_ENV_MAX_BYTES = 4 * 1024 * 1024 +export const DAEMON_PTY_ENV_DELETE_MAX_ENTRIES = 4096 +export const DAEMON_PTY_ENV_DELETE_MAX_BYTES = 1024 * 1024 + +const REQUESTS_WITH_SESSION_ID = new Set([ + 'cancelCreateOrAttach', + 'closeStartupQueryAuthority', + 'write', + 'resize', + 'pausePty', + 'resumePty', + 'setSessionBackground', + 'kill', + 'signal', + 'detach', + 'getCwd', + 'getForegroundProcess', + 'confirmForegroundProcess', + 'clearScrollback', + 'getSnapshot', + 'getSize', + 'takePendingOutput' +]) + +type JsonRecord = Record + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function boundedStringError( + value: unknown, + field: string, + maxBytes: number, + options: { nonEmpty?: boolean; forbidNull?: boolean } = {} +): string | null { + if (typeof value !== 'string') { + return `${field} must be a string` + } + if (options.nonEmpty && value.length === 0) { + return `${field} must not be empty` + } + if (options.forbidNull && value.includes('\0')) { + return `${field} must not contain NUL` + } + if ( + value.length > maxBytes || + measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit + ) { + return `${field} exceeds ${maxBytes} bytes` + } + return null +} + +function optionalBoundedStringError( + value: unknown, + field: string, + maxBytes: number +): string | null { + return value === undefined ? null : boundedStringError(value, field, maxBytes) +} + +function optionalNullableBoundedStringError( + value: unknown, + field: string, + maxBytes: number +): string | null { + return value === undefined || value === null ? null : boundedStringError(value, field, maxBytes) +} + +function createEnvironmentError(env: unknown): string | null { + if (env === undefined) { + return null + } + if (!isRecord(env)) { + return 'createOrAttach payload.env must be a string record' + } + + let entries = 0 + let retainedBytes = 0 + for (const name in env) { + if (!Object.prototype.hasOwnProperty.call(env, name)) { + continue + } + entries += 1 + if (entries > DAEMON_PTY_ENV_MAX_ENTRIES) { + return `createOrAttach payload.env exceeds ${DAEMON_PTY_ENV_MAX_ENTRIES} entries` + } + const nameError = boundedStringError( + name, + 'createOrAttach payload.env name', + DAEMON_PTY_ENV_NAME_MAX_BYTES + ) + if (nameError) { + return nameError + } + const value = env[name] + const valueError = boundedStringError( + value, + 'createOrAttach payload.env value', + DAEMON_PTY_ENV_VALUE_MAX_BYTES + ) + if (valueError) { + return valueError + } + retainedBytes += + measureUtf8ByteLength(name).byteLength + measureUtf8ByteLength(value as string).byteLength + if (retainedBytes > DAEMON_PTY_ENV_MAX_BYTES) { + return `createOrAttach payload.env exceeds ${DAEMON_PTY_ENV_MAX_BYTES} bytes` + } + } + return null +} + +function createEnvironmentDeleteError(envToDelete: unknown): string | null { + if (envToDelete === undefined) { + return null + } + if (!Array.isArray(envToDelete)) { + return 'createOrAttach payload.envToDelete must be a string array' + } + if (envToDelete.length > DAEMON_PTY_ENV_DELETE_MAX_ENTRIES) { + return `createOrAttach payload.envToDelete exceeds ${DAEMON_PTY_ENV_DELETE_MAX_ENTRIES} entries` + } + + let retainedBytes = 0 + for (const name of envToDelete) { + const nameError = boundedStringError( + name, + 'createOrAttach payload.envToDelete entry', + DAEMON_PTY_ENV_NAME_MAX_BYTES + ) + if (nameError) { + return nameError + } + retainedBytes += measureUtf8ByteLength(name as string).byteLength + if (retainedBytes > DAEMON_PTY_ENV_DELETE_MAX_BYTES) { + return `createOrAttach payload.envToDelete exceeds ${DAEMON_PTY_ENV_DELETE_MAX_BYTES} bytes` + } + } + return null +} + +function createOrAttachPayloadError(payload: unknown): string | null { + if (!isRecord(payload)) { + return 'createOrAttach payload must be an object' + } + + const checks = [ + () => + boundedStringError( + payload.sessionId, + 'createOrAttach payload.sessionId', + DAEMON_SESSION_ID_MAX_BYTES, + { nonEmpty: true, forbidNull: true } + ), + () => + optionalBoundedStringError( + payload.cwd, + 'createOrAttach payload.cwd', + DAEMON_PTY_CWD_MAX_BYTES + ), + () => + optionalBoundedStringError( + payload.command, + 'createOrAttach payload.command', + DAEMON_PTY_COMMAND_MAX_BYTES + ), + () => + optionalBoundedStringError( + payload.historySeed, + 'createOrAttach payload.historySeed', + DAEMON_PTY_HISTORY_SEED_MAX_BYTES + ), + () => + optionalBoundedStringError( + payload.shellOverride, + 'createOrAttach payload.shellOverride', + DAEMON_PTY_CWD_MAX_BYTES + ), + () => + optionalNullableBoundedStringError( + payload.terminalWindowsWslDistro, + 'createOrAttach payload.terminalWindowsWslDistro', + DAEMON_PTY_CWD_MAX_BYTES + ), + () => + terminalSizeAdmissionError(payload.cols, payload.rows, 'createOrAttach payload', { + allowMissing: true + }), + () => createEnvironmentError(payload.env), + () => createEnvironmentDeleteError(payload.envToDelete) + ] + for (const check of checks) { + const error = check() + if (error) { + return error + } + } + return null +} + +export function daemonHelloAdmissionError(value: unknown): string | null { + if (!isRecord(value) || value.type !== 'hello') { + return 'Expected hello' + } + if (value.role !== 'control' && value.role !== 'stream') { + return 'Invalid hello role' + } + return boundedStringError(value.clientId, 'hello clientId', DAEMON_CLIENT_ID_MAX_BYTES, { + nonEmpty: true, + forbidNull: true + }) +} + +export function daemonRequestAdmissionError(value: unknown): string | null { + if (!isRecord(value)) { + return 'Daemon request must be an object' + } + const idError = boundedStringError(value.id, 'Daemon request id', DAEMON_REQUEST_ID_MAX_BYTES, { + nonEmpty: true, + forbidNull: true + }) + if (idError) { + return idError + } + const typeError = boundedStringError( + value.type, + 'Daemon request type', + DAEMON_REQUEST_TYPE_MAX_BYTES, + { nonEmpty: true } + ) + if (typeError) { + return typeError + } + if (value.type === 'createOrAttach') { + return createOrAttachPayloadError(value.payload) + } + if (!REQUESTS_WITH_SESSION_ID.has(value.type as string)) { + return null + } + if (!isRecord(value.payload)) { + return `${value.type as string} payload must be an object` + } + const sessionIdError = boundedStringError( + value.payload.sessionId, + `${value.type as string} payload.sessionId`, + DAEMON_SESSION_ID_MAX_BYTES, + { nonEmpty: true, forbidNull: true } + ) + if (sessionIdError) { + return sessionIdError + } + return value.type === 'resize' + ? terminalSizeAdmissionError(value.payload.cols, value.payload.rows, 'resize payload') + : null +} + +export function getBoundedDaemonRequestId(value: unknown): string | null { + if (!isRecord(value)) { + return null + } + return boundedStringError(value.id, 'Daemon request id', DAEMON_REQUEST_ID_MAX_BYTES, { + nonEmpty: true, + forbidNull: true + }) === null + ? (value.id as string) + : null +} diff --git a/src/main/daemon/daemon-client-outbound-admission.test.ts b/src/main/daemon/daemon-client-outbound-admission.test.ts new file mode 100644 index 00000000000..33b3a983ea5 --- /dev/null +++ b/src/main/daemon/daemon-client-outbound-admission.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DAEMON_CLIENT_MAX_CONTROL_BUFFERED_BYTES, + DAEMON_CLIENT_MAX_PENDING_REQUESTS, + DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES, + DaemonClient +} from './client' + +type PendingRequest = { + resolve: (value: unknown) => void + reject: (reason: Error) => void + timer: ReturnType +} + +type FakeControlSocket = { + writableLength: number + write: ReturnType + destroy: ReturnType +} + +function createConnectedClient(): { + client: DaemonClient + socket: FakeControlSocket + pendingRequests: Map +} { + const client = new DaemonClient({ socketPath: 'unused', tokenPath: 'unused' }) + const socket: FakeControlSocket = { + writableLength: 0, + write: vi.fn(() => true), + destroy: vi.fn() + } + const pendingRequests = new Map() + const state = client as unknown as { + connected: boolean + controlSocket: FakeControlSocket + pendingRequests: Map + } + state.connected = true + state.controlSocket = socket + state.pendingRequests = pendingRequests + return { client, socket, pendingRequests } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('DaemonClient outbound admission', () => { + it('rejects before serialization when the pending request cap is full', async () => { + vi.useFakeTimers() + const { client, socket, pendingRequests } = createConnectedClient() + for (let index = 0; index < DAEMON_CLIENT_MAX_PENDING_REQUESTS; index += 1) { + pendingRequests.set(`request-${index}`, { + resolve: () => {}, + reject: () => {}, + timer: setTimeout(() => {}, 60_000) + }) + } + + await expect( + client.request('write', { data: 'x'.repeat(DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES) }) + ).rejects.toThrow('pending request limit') + + expect(socket.write).not.toHaveBeenCalled() + expect(pendingRequests.size).toBe(DAEMON_CLIENT_MAX_PENDING_REQUESTS) + for (const pending of pendingRequests.values()) { + clearTimeout(pending.timer) + } + }) + + it('rejects a request whose serialized line exceeds the daemon contract', async () => { + const { client, socket, pendingRequests } = createConnectedClient() + + await expect( + client.request('write', { + data: 'x'.repeat(DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES) + }) + ).rejects.toThrow() + + expect(socket.write).not.toHaveBeenCalled() + expect(pendingRequests.size).toBe(0) + }) + + it('rejects and closes before growing a saturated control buffer', async () => { + const { client, socket, pendingRequests } = createConnectedClient() + socket.writableLength = DAEMON_CLIENT_MAX_CONTROL_BUFFERED_BYTES + + await expect(client.request('listSessions', undefined)).rejects.toThrow('control buffer limit') + + expect(socket.write).not.toHaveBeenCalled() + expect(socket.destroy).toHaveBeenCalledOnce() + expect(pendingRequests.size).toBe(0) + }) + + it('releases request admission when socket.write throws', async () => { + vi.useFakeTimers() + const { client, socket, pendingRequests } = createConnectedClient() + socket.write.mockImplementation(() => { + throw new Error('write failed') + }) + + await expect(client.request('listSessions', undefined)).rejects.toThrow('write failed') + + expect(pendingRequests.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('drops a notification and closes before growing a saturated control buffer', () => { + const { client, socket } = createConnectedClient() + socket.writableLength = DAEMON_CLIENT_MAX_CONTROL_BUFFERED_BYTES + + client.notify('write', { sessionId: 'session-a', data: 'hello' }) + + expect(socket.write).not.toHaveBeenCalled() + expect(socket.destroy).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/daemon/daemon-control-file-reader.test.ts b/src/main/daemon/daemon-control-file-reader.test.ts new file mode 100644 index 00000000000..63fde6f746c --- /dev/null +++ b/src/main/daemon/daemon-control-file-reader.test.ts @@ -0,0 +1,40 @@ +import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + MAX_DAEMON_CONTROL_FILE_BYTES, + readDaemonControlFileText +} from './daemon-control-file-reader' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function createPath(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-daemon-control-')) + roots.push(root) + return join(root, 'control') +} + +describe('readDaemonControlFileText', () => { + it('accepts a control file exactly at the byte cap', () => { + const filePath = createPath() + writeFileSync(filePath, Buffer.alloc(MAX_DAEMON_CONTROL_FILE_BYTES, 0x61)) + + expect(readDaemonControlFileText(filePath)).toHaveLength(MAX_DAEMON_CONTROL_FILE_BYTES) + }) + + it('rejects a sparse control file beyond the byte cap', () => { + const filePath = createPath() + const file = openSync(filePath, 'w') + ftruncateSync(file, MAX_DAEMON_CONTROL_FILE_BYTES + 1) + closeSync(file) + + expect(() => readDaemonControlFileText(filePath)).toThrow('exceeds') + }) +}) diff --git a/src/main/daemon/daemon-control-file-reader.ts b/src/main/daemon/daemon-control-file-reader.ts new file mode 100644 index 00000000000..5ee479462da --- /dev/null +++ b/src/main/daemon/daemon-control-file-reader.ts @@ -0,0 +1,9 @@ +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' + +export const MAX_DAEMON_CONTROL_FILE_BYTES = 64 * 1024 + +export function readDaemonControlFileText(filePath: string): string { + return readNodeFileSyncWithinLimit(filePath, MAX_DAEMON_CONTROL_FILE_BYTES).buffer.toString( + 'utf8' + ) +} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index ba98af21c5b..b422e30dc85 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -6,7 +6,6 @@ * Signals readiness to parent via IPC: { type: 'ready' } * Shuts down cleanly on SIGTERM. */ -import { readFileSync } from 'node:fs' import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' import { warmWindowsConptyOnce } from './windows-conpty-warmup' @@ -17,6 +16,7 @@ import { prepareMacosTccLoginShell, probeMacosLoginSessionAlive } from '../providers/macos-tcc-login-shell' +import { readDaemonLoginSessionProbeVerdict } from './daemon-login-session-probe-verdict' import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' @@ -176,13 +176,7 @@ async function main(): Promise { // 'alive' → accepted/healthy, 'dead' → rejected/unhealthy, 'hang' → // timeout-inconclusive/unhealthy (the fail-safe path), else inconclusive. const e2eProbeFile = process.env.ORCA_E2E_LOGIN_SESSION_PROBE_FILE - const readE2eVerdict = (): string => { - try { - return readFileSync(e2eProbeFile as string, 'utf8').trim() - } catch { - return '' - } - } + const readE2eVerdict = (): string => readDaemonLoginSessionProbeVerdict(e2eProbeFile as string) deathWatch = loginSessionWatch && process.platform === 'darwin' ? new MacosLoginSessionDeathWatch({ diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index edefd8fa02b..f50a2d4e0ac 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(28) + expect(PROTOCOL_VERSION).toBe(27) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) diff --git a/src/main/daemon/daemon-health-socket-cleanup.test.ts b/src/main/daemon/daemon-health-socket-cleanup.test.ts index 6daa6cdf00b..0bb7e5f7207 100644 --- a/src/main/daemon/daemon-health-socket-cleanup.test.ts +++ b/src/main/daemon/daemon-health-socket-cleanup.test.ts @@ -10,7 +10,8 @@ const { netConnectMock } = vi.hoisted(() => ({ vi.mock('net', () => ({ connect: netConnectMock })) -import { healthCheckDaemon, killStaleDaemon } from './daemon-health' +import { checkDaemonHealth, healthCheckDaemon, killStaleDaemon } from './daemon-health' +import { DAEMON_HANDSHAKE_MAX_LINE_BYTES } from './ndjson' class FakeSocket extends EventEmitter { destroy = vi.fn() @@ -71,6 +72,21 @@ describe('daemon health socket listener cleanup', () => { expect(socket.destroy).toHaveBeenCalledTimes(1) }) + it('rejects and releases a newline-free oversized health response', async () => { + const socket = new FakeSocket() + netConnectMock.mockReturnValueOnce(socket) + + const result = checkDaemonHealth(socketPath, tokenPath) + socket.emit('connect') + socket.emit('data', Buffer.alloc(DAEMON_HANDSHAKE_MAX_LINE_BYTES + 1, 0x78)) + + await expect(result).resolves.toBe('rejected') + expect(socket.listenerCount('connect')).toBe(0) + expect(socket.listenerCount('error')).toBe(0) + expect(socket.listenerCount('data')).toBe(0) + expect(socket.destroy).toHaveBeenCalledOnce() + }) + it('removes stale-socket probe listeners after a timeout', async () => { vi.useFakeTimers() const socket = new FakeSocket() diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 50e7226f73c..5912c4f501c 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -4,12 +4,13 @@ import { execFile, execFileSync } from 'node:child_process' import { existsSync, readFileSync, unlinkSync } from 'node:fs' import { connect, type Socket } from 'node:net' import { promisify } from 'node:util' +import { StringDecoder } from 'node:string_decoder' import { getProcessOutputFields, iterateProcessOutputLines } from '../../shared/process-output-field-scanner' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' -import { encodeNdjson } from './ndjson' +import { DAEMON_HANDSHAKE_MAX_LINE_BYTES, createNdjsonParser, encodeNdjson } from './ndjson' import { getDaemonPidPath } from './daemon-spawner' import { PROTOCOL_VERSION, @@ -18,12 +19,17 @@ import { type SystemResolverHealth, type SystemResolverHealthResult } from './types' +import { readDaemonControlFileText } from './daemon-control-file-reader' const HEALTH_CHECK_TIMEOUT_MS = 3_000 const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000 const KILL_WAIT_MS = 3_000 const KILL_POLL_MS = 100 const START_TIME_TOLERANCE_MS = 1_500 +// Why: e2e forces the failed-health preserve path without SIGSTOP races — +// a stopped daemon also blocks listSessions, so the unhealthy guard cannot +// verify live sessions until SIGCONT, which is flaky under CI load. +export const E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV = 'ORCA_E2E_FORCE_DAEMON_HEALTH_UNREACHABLE' // Why: on Windows the pid file's startedAtMs is the daemon's self-reported // Node start time, while verification reads the OS process creation time — // the gap between them is the exe bootstrap, which AV/disk pressure can @@ -82,6 +88,11 @@ function canConnectSocket(socketPath: string): Promise { export function checkDaemonHealth(socketPath: string, tokenPath: string): Promise { return new Promise((resolve) => { + if (process.env[E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV] === '1') { + resolve('unreachable') + return + } + if (process.platform !== 'win32' && !existsSync(socketPath)) { resolve('unreachable') return @@ -89,7 +100,7 @@ export function checkDaemonHealth(socketPath: string, tokenPath: string): Promis let token: string try { - token = readFileSync(tokenPath, 'utf8').trim() + token = readDaemonControlFileText(tokenPath).trim() } catch { resolve('unreachable') return @@ -123,55 +134,42 @@ export function checkDaemonHealth(socketPath: string, tokenPath: string): Promis } sock?.write(encodeNdjson(hello)) } - const onData = (chunk: Buffer): void => { + const onMessage = (rawMessage: unknown): void => { if (settled) { return } - buffer += chunk.toString() - for (;;) { - const newlineIdx = buffer.indexOf('\n') - if (newlineIdx === -1) { - break - } - const line = buffer.slice(0, newlineIdx) - buffer = buffer.slice(newlineIdx + 1) - if (!line) { - continue - } - - let message: Record - try { - message = JSON.parse(line) as Record - } catch { + if (!rawMessage || typeof rawMessage !== 'object') { + settle('rejected') + return + } + const message = rawMessage as Record + if (message.type === 'hello') { + if (!(message as HelloResponse).ok) { settle('rejected') return } + // Why: a protocol-live daemon with a stale cwd or node-pty helper + // will answer ping but cannot create terminals, so reuse must check + // the PTY spawn prerequisites too. + sock?.write(encodeNdjson({ id: 'health-1', type: 'ptySpawnHealth' })) + return + } - if (message.type === 'hello') { - if (!(message as HelloResponse).ok) { - settle('rejected') - return - } - // Why: a protocol-live daemon with a stale cwd or node-pty helper - // will answer ping but cannot create terminals, so reuse must check - // the PTY spawn prerequisites too. - sock?.write(encodeNdjson({ id: 'health-1', type: 'ptySpawnHealth' })) - continue - } - - if (message.id === 'health-1') { - settle(message.ok === true ? 'healthy' : 'pty-spawn-unhealthy') - return - } + if (message.id === 'health-1') { + settle(message.ok === true ? 'healthy' : 'pty-spawn-unhealthy') } } + const decoder = new StringDecoder('utf8') + const parser = createNdjsonParser(onMessage, () => settle('rejected'), { + maxLineBytes: DAEMON_HANDSHAKE_MAX_LINE_BYTES + }) + const onData = (chunk: Buffer): void => parser.feed(decoder.write(chunk)) const timer = setTimeout(() => settle('unreachable'), HEALTH_CHECK_TIMEOUT_MS) sock = connect({ path: socketPath }) sock.on('error', onError) sock.on('connect', onConnect) - let buffer = '' sock.on('data', onData) }) } @@ -201,7 +199,7 @@ export function getMacDaemonSystemResolverHealth( let token: string try { - token = readFileSync(tokenPath, 'utf8').trim() + token = readDaemonControlFileText(tokenPath).trim() } catch { resolve('unknown') return @@ -235,59 +233,46 @@ export function getMacDaemonSystemResolverHealth( } sock?.write(encodeNdjson(hello)) } - const onData = (chunk: Buffer): void => { + const onMessage = (rawMessage: unknown): void => { if (settled) { return } - buffer += chunk.toString() - for (;;) { - const newlineIdx = buffer.indexOf('\n') - if (newlineIdx === -1) { - break - } - const line = buffer.slice(0, newlineIdx) - buffer = buffer.slice(newlineIdx + 1) - if (!line) { - continue - } - - let message: Record - try { - message = JSON.parse(line) as Record - } catch { + if (!rawMessage || typeof rawMessage !== 'object') { + settle('unknown') + return + } + const message = rawMessage as Record + if (message.type === 'hello') { + if (!(message as HelloResponse).ok) { settle('unknown') return } + // Why: the daemon must report health from inside its own process; + // external launchctl bsexec probes can misclassify healthy PTYs. + sock?.write(encodeNdjson({ id: 'resolver-health-1', type: 'systemResolverHealth' })) + return + } - if (message.type === 'hello') { - if (!(message as HelloResponse).ok) { - settle('unknown') - return - } - // Why: the daemon must report health from inside its own process; - // external launchctl bsexec probes can misclassify healthy PTYs. - sock?.write(encodeNdjson({ id: 'resolver-health-1', type: 'systemResolverHealth' })) - continue - } - - if (message.id === 'resolver-health-1') { - if (!message.ok || typeof message.payload !== 'object' || message.payload === null) { - settle('unknown') - return - } - const payload = message.payload as Partial - settle(isSystemResolverHealth(payload.health) ? payload.health : 'unknown') + if (message.id === 'resolver-health-1') { + if (!message.ok || typeof message.payload !== 'object' || message.payload === null) { + settle('unknown') return } + const payload = message.payload as Partial + settle(isSystemResolverHealth(payload.health) ? payload.health : 'unknown') } } + const decoder = new StringDecoder('utf8') + const parser = createNdjsonParser(onMessage, () => settle('unknown'), { + maxLineBytes: DAEMON_HANDSHAKE_MAX_LINE_BYTES + }) + const onData = (chunk: Buffer): void => parser.feed(decoder.write(chunk)) const timer = setTimeout(() => settle('unknown'), RESOLVER_HEALTH_CHECK_TIMEOUT_MS) sock = connect({ path: socketPath }) sock.on('error', onError) sock.on('connect', onConnect) - let buffer = '' sock.on('data', onData) }) } @@ -591,7 +576,7 @@ async function readVerifiedDaemonPid( let parsedPid: ParsedDaemonPid | null try { parsedPid = parseDaemonPidFile( - readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8') + readDaemonControlFileText(getDaemonPidPath(runtimeDir, protocolVersion)) ) } catch { return null @@ -638,7 +623,7 @@ export async function killStaleDaemon( const pidPath = getDaemonPidPath(runtimeDir, protocolVersion) let killedDaemon = false try { - const parsedPid = parseDaemonPidFile(readFileSync(pidPath, 'utf8')) + const parsedPid = parseDaemonPidFile(readDaemonControlFileText(pidPath)) if ( parsedPid && (await isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 4618fdbdfe5..0e8e3410600 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -3,8 +3,7 @@ import { cpSync, existsSync, mkdirSync, - readFileSync, - readdirSync, + opendirSync, renameSync, rmSync, writeFileSync @@ -12,6 +11,7 @@ import { import { dirname, join, win32 as winPath } from 'node:path' import { app } from 'electron' import { parseDaemonPidFile, startTimeMatches } from './daemon-health' +import { readDaemonControlFileText } from './daemon-control-file-reader' /** * Relocate the terminal daemon's process image out of the app install dir into LOCAL userData so it @@ -30,6 +30,7 @@ export type RelocatedDaemonHost = { const HOST_SUBDIR = 'daemon-host' const MARKER_NAME = '.materialized.json' +const MAX_PINNED_DAEMON_VERSIONS = 1024 // LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync. const LOCAL_HOST_ROOT_NAME = 'Orca' @@ -184,7 +185,7 @@ function executeManifest(ops: CopyOp[], stagingRoot: string): void { function readMarker(dir: string): MaterializeMarker | null { try { const parsed = JSON.parse( - readFileSync(join(dir, MARKER_NAME), 'utf8') + readDaemonControlFileText(join(dir, MARKER_NAME)) ) as Partial if (typeof parsed.version === 'string' && typeof parsed.entryRelPath === 'string') { return { @@ -289,25 +290,41 @@ function isDaemonPidAlive(pid: number, startedAtMs: number | null): boolean { */ export function collectPinnedDaemonVersions(runtimeDir: string): Set { const pinned = new Set() - let entries + let directory: ReturnType try { - entries = readdirSync(runtimeDir, { withFileTypes: true }) + directory = opendirSync(runtimeDir) } catch { return pinned } - for (const entry of entries) { - if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { - continue + try { + while (pinned.size < MAX_PINNED_DAEMON_VERSIONS) { + const entry = directory.readSync() + if (!entry) { + break + } + if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { + continue + } + let parsed + try { + parsed = parseDaemonPidFile(readDaemonControlFileText(join(runtimeDir, entry.name))) + } catch { + continue + } + // appVersion null => pre-relocation daemon forked from the install dir; pins no host dir here. + if ( + parsed && + parsed.appVersion !== null && + isDaemonPidAlive(parsed.pid, parsed.startedAtMs) + ) { + pinned.add(parsed.appVersion) + } } - let parsed + } finally { try { - parsed = parseDaemonPidFile(readFileSync(join(runtimeDir, entry.name), 'utf8')) + directory.closeSync() } catch { - continue - } - // appVersion null => pre-relocation daemon forked from the install dir; pins no host dir here. - if (parsed && parsed.appVersion !== null && isDaemonPidAlive(parsed.pid, parsed.startedAtMs)) { - pinned.add(parsed.appVersion) + // Best-effort discovery cleanup. } } return pinned @@ -321,22 +338,38 @@ export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { if (process.platform !== 'win32' || !app.isPackaged) { return } + // Why: an incomplete saturated pin scan must fail closed rather than remove a live daemon image. + if (pinnedVersions.size >= MAX_PINNED_DAEMON_VERSIONS) { + return + } const version = app.getVersion() const root = hostRootDir() - let entries + let directory: ReturnType try { - entries = readdirSync(root, { withFileTypes: true }) + directory = opendirSync(root) } catch { return } - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === version || pinnedVersions.has(entry.name)) { - continue + try { + while (true) { + const entry = directory.readSync() + if (!entry) { + break + } + if (!entry.isDirectory() || entry.name === version || pinnedVersions.has(entry.name)) { + continue + } + try { + rmSync(join(root, entry.name), { recursive: true, force: true }) + } catch { + // Still locked or already gone — retry on a future launch. + } } + } finally { try { - rmSync(join(root, entry.name), { recursive: true, force: true }) + directory.closeSync() } catch { - // Still locked or already gone — retry on a future launch. + // Best-effort pruning cleanup. } } } diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 5a3003f767d..504ee6e6277 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -235,6 +235,10 @@ vi.mock('fs', () => ({ writeFileSync: writeFileSyncMock })) +vi.mock('./daemon-control-file-reader', () => ({ + readDaemonControlFileText: readFileSyncMock +})) + vi.mock('child_process', () => ({ fork: forkMock })) vi.mock('net', () => ({ connect: netConnectMock })) @@ -2160,6 +2164,64 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalled() }) + it('stays silent about replacing a daemon on a cold start, where there is none', async () => { + // Why: a first launch reaches the same replace fall-through (unreachable health, + // no socket, nothing to probe); announcing a replacement there reports killing a + // daemon that never existed, on the most common path there is. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + // Both pre-spawn probes fail: nothing ever answers, so no session count is observed. + const unreachableClient = function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('connect ENOENT') + }), + request: vi.fn(), + disconnect: vi.fn() + } + } + daemonClientMock + .mockImplementationOnce(unreachableClient) + .mockImplementationOnce(unreachableClient) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('unreachable') + probeSocketExistsMock.mockReturnValue(false) + forkMock.mockImplementationOnce(() => ({ + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) + } + return this + }, + once() { + return this + }, + off() { + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + })) + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await launcher('/fake/socket', '/fake/token') + + expect(forkMock).toHaveBeenCalled() + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Replacing daemon that failed the health check') + ) + } finally { + warnSpy.mockRestore() + } + }) + // Why: net.connect stub whose 'connect' fires, so probeSocket() reports the pipe alive on every grace re-check. function stubAliveSocketConnect() { const handlers: Record void)[]> = { connect: [], error: [] } @@ -2267,6 +2329,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { // Count only the launcher's own session-count probes. daemonClientMock.mockClear() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { await launcher('/fake/socket', '/fake/token') @@ -2279,7 +2342,16 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalled() // The launcher probes the full grace budget: 1 initial probe + WEDGED_DAEMON_GRACE_RETRIES retries. expect(daemonClientMock).toHaveBeenCalledTimes(3 + WEDGED_DAEMON_GRACE_RETRIES) + // Why: this replace path used to kill the daemon with no log, so a post-hoc + // reader could not tell it apart from an adoption; the verdict must be recorded. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Replacing daemon that failed the health check') + ) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(`graceRetries=${WEDGED_DAEMON_GRACE_RETRIES}`) + ) } finally { + warnSpy.mockRestore() // Restore the answering default: clearAllMocks clears calls not impls, so the throwing impl would leak into later tests. daemonClientMock.mockImplementation(answeringDefault) } diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index a748ea10a8b..b7cad816bdb 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -3,7 +3,7 @@ restart, teardown); the "swap the provider atomically" invariant keeps restart + import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { app } from 'electron' -import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs' import { fork, type ChildProcess } from 'node:child_process' import { connect } from 'node:net' import { @@ -51,6 +51,7 @@ import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys } from '../claude-accounts/live-pty-gate' +import { readDaemonControlFileText } from './daemon-control-file-reader' // Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start. function logDaemonMilestone(event: string, details: Record = {}): void { @@ -428,6 +429,17 @@ function createOutOfProcessLauncher( ) return preserveDaemon() } + // Why: the sibling replace branches announce themselves, but this one used + // to kill a daemon silently — leaving no way to tell a replacement apart + // from an adoption after the fact. A cold start also lands here with + // nothing to replace, so only speak up once something actually answered: + // a probe that returned a count, a socket that survived a grace retry, or + // a refused hello. + if (liveSessionCount !== null || graceRetry > 0 || health === 'rejected') { + console.warn( + `[daemon] Replacing daemon that failed the health check (health=${health}, liveSessions=${liveSessionCount ?? 'unverifiable'}, graceRetries=${graceRetry})` + ) + } } // Why: a raw socket can outlive a broken daemon; kill by PID before respawn so the new daemon doesn't race the stale one. @@ -1015,7 +1027,7 @@ async function waitForDaemonEndpointExit(socketPath: string): Promise { function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean { try { const parsed = parseDaemonPidFile( - readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8') + readDaemonControlFileText(getDaemonPidPath(runtimeDir, protocolVersion)) ) if (!parsed) { return false diff --git a/src/main/daemon/daemon-login-session-probe-verdict.test.ts b/src/main/daemon/daemon-login-session-probe-verdict.test.ts new file mode 100644 index 00000000000..e870d113aff --- /dev/null +++ b/src/main/daemon/daemon-login-session-probe-verdict.test.ts @@ -0,0 +1,33 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + MAX_DAEMON_LOGIN_SESSION_PROBE_BYTES, + readDaemonLoginSessionProbeVerdict +} from './daemon-login-session-probe-verdict' + +describe('daemon login-session probe verdict', () => { + const cleanupPaths: string[] = [] + + afterEach(async () => { + await Promise.all(cleanupPaths.splice(0).map((path) => rm(path, { recursive: true }))) + }) + + async function createProbe(content: string): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-daemon-login-probe-')) + cleanupPaths.push(directory) + const path = join(directory, 'verdict') + await writeFile(path, content) + return path + } + + it('reads a normal verdict', async () => { + expect(readDaemonLoginSessionProbeVerdict(await createProbe(' alive\n'))).toBe('alive') + }) + + it('fails inconclusive when the verdict file exceeds its cap', async () => { + const path = await createProbe('x'.repeat(MAX_DAEMON_LOGIN_SESSION_PROBE_BYTES + 1)) + expect(readDaemonLoginSessionProbeVerdict(path)).toBe('') + }) +}) diff --git a/src/main/daemon/daemon-login-session-probe-verdict.ts b/src/main/daemon/daemon-login-session-probe-verdict.ts new file mode 100644 index 00000000000..18238e9e681 --- /dev/null +++ b/src/main/daemon/daemon-login-session-probe-verdict.ts @@ -0,0 +1,13 @@ +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' + +export const MAX_DAEMON_LOGIN_SESSION_PROBE_BYTES = 64 + +export function readDaemonLoginSessionProbeVerdict(filePath: string): string { + try { + return readNodeFileSyncWithinLimit(filePath, MAX_DAEMON_LOGIN_SESSION_PROBE_BYTES) + .buffer.toString('utf8') + .trim() + } catch { + return '' + } +} diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 1b7498e9962..20a9d602353 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -3,18 +3,20 @@ import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './daemon-protocol-version' describe('daemon protocol version', () => { - it('ships preflight-cache replacement after completion inspection', () => { - expect(PROTOCOL_VERSION).toBe(28) + it('ships strict completion inspection after claim authority', () => { + expect(PROTOCOL_VERSION).toBe(27) expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27) + expect(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(11) expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 27 }, (_, index) => index + 1) + Array.from({ length: 26 }, (_, index) => index + 1) ) }) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index c5b4f2c1b20..2878986d3a2 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,14 +1,14 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v28 replaces v26/v27 daemons that can retain permanent macOS preflight rejections (#9756). -export const PROTOCOL_VERSION = 28 +export const PROTOCOL_VERSION = 27 export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27 +export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11 export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25 export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26 export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26 export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 2ad8ef28fce..ce68f358799 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -6,7 +6,11 @@ import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync import { DaemonClient } from './client' import { DaemonProtocolError } from './daemon-errors' import { DaemonPtyAdapter } from './daemon-pty-adapter' -import { COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION } from './daemon-protocol-version' +import { + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + PROTOCOL_VERSION +} from './daemon-protocol-version' import { DaemonServer } from './daemon-server' import { HeadlessEmulator } from './headless-emulator' import { getHistorySessionDirName } from './history-paths' @@ -869,6 +873,60 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('listProcesses', () => { + it('strips unknown daemon owner payloads before publication', () => { + const normalize = ( + adapter as unknown as { + validatedAgentSessionOwners(owners: unknown): { + agentSessionOwners?: unknown[] + } + } + ).validatedAgentSessionOwners.bind(adapter) + const owner = { + claim: { + digestVersion: 1, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex', + unknownPayload: 'claim payload' + }, + generation: 'generation-1', + phase: 'live', + ptyId: 'pty-1', + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed', + unknownPayload: 'surface payload' + }, + unknownPayload: 'owner payload' + } + + expect(normalize([owner])).toEqual({ + agentSessionOwners: [ + { + claim: { + digestVersion: 1, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' + }, + generation: 'generation-1', + phase: 'live', + ptyId: 'pty-1', + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + } + ] + }) + }) + it('returns active sessions', async () => { await adapter.spawn({ cols: 80, @@ -929,31 +987,53 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('inspectProcess on pre-inspection daemon protocols', () => { - // Why: daemons outlive an in-place app update, so a v26 daemon must still answer inspections - // client-side; throwing here leaves agent-completion detection permanently retrying. type ClientInternals = { client: { request: ReturnType; disconnect: ReturnType } } - function createLegacyAdapter(request: ReturnType): DaemonPtyAdapter { - const legacy = new DaemonPtyAdapter({ + function createInspectionAdapter( + protocolVersion: number, + request: ReturnType + ): DaemonPtyAdapter { + const inspectionAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, - protocolVersion: COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1 + protocolVersion }) - ;(legacy as unknown as ClientInternals).client = { request, disconnect: vi.fn() } - return legacy + ;(inspectionAdapter as unknown as ClientInternals).client = { + request, + disconnect: vi.fn() + } + return inspectionAdapter } - it('composes a real inspection from the foreground call the daemon does support', async () => { + it('reports protocol 10 inspection as unavailable without unsupported RPCs', async () => { + const request = vi.fn() + const legacy = createInspectionAdapter(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION - 1, request) + + await expect(legacy.inspectProcess('sess-a')).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + await expect(legacy.getForegroundProcess('sess-a')).resolves.toBeNull() + await expect(legacy.hasChildProcesses('sess-a')).resolves.toBe(true) + expect(request).not.toHaveBeenCalled() + + legacy.dispose() + }) + + it.each([ + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1 + ])('composes protocol %s inspection from getForegroundProcess', async (protocolVersion) => { const request = vi.fn(async () => ({ foregroundProcess: 'codex' })) - const legacy = createLegacyAdapter(request) + const legacy = createInspectionAdapter(protocolVersion, request) expect(await legacy.inspectProcess('sess-a')).toEqual({ foregroundProcess: 'codex', hasChildProcesses: true }) - // Why: the fallback must not depend on a capability the legacy daemon lacks. expect(request).toHaveBeenCalledWith('getForegroundProcess', { sessionId: 'sess-a' }) expect(request).not.toHaveBeenCalledWith('inspectProcess', expect.anything()) @@ -961,7 +1041,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) it('reports an idle shell as having no child processes', async () => { - const legacy = createLegacyAdapter(vi.fn(async () => ({ foregroundProcess: 'bash' }))) + const legacy = createInspectionAdapter( + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1, + vi.fn(async () => ({ foregroundProcess: 'bash' })) + ) expect(await legacy.inspectProcess('sess-a')).toEqual({ foregroundProcess: 'bash', @@ -972,11 +1055,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) it('reports a null foreground as idle, matching what the legacy daemon can report', async () => { - // Why: pins the one response shape whose semantics differ from v27, where inspectProcess goes - // through getAliveSession() and throws for a vanished session while getForegroundProcess stays - // null-not-throw. Reading it as idle is deliberate — this is also the only shape that reaches a - // user-visible completion — so the divergence must not change silently. - const legacy = createLegacyAdapter(vi.fn(async () => ({ foregroundProcess: null }))) + const legacy = createInspectionAdapter( + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1, + vi.fn(async () => ({ foregroundProcess: null })) + ) expect(await legacy.inspectProcess('sess-a')).toEqual({ foregroundProcess: null, @@ -989,7 +1071,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it('rejects rather than reading as idle when the daemon call fails', async () => { // Why: getForegroundProcess swallows errors into null; composing through it would turn a dead // socket into a false "agent exited" completion, the mirror of the bug this path fixes. - const legacy = createLegacyAdapter( + const legacy = createInspectionAdapter( + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1, vi.fn(async () => { throw new Error('socket_closed') }) @@ -1000,21 +1083,19 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { legacy.dispose() }) - it('still delegates to the daemon once the protocol supports inspectProcess', async () => { - const request = vi.fn(async () => ({ foregroundProcess: 'codex', hasChildProcesses: true })) - const current = new DaemonPtyAdapter({ - socketPath, - tokenPath, - protocolVersion: COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - }) - ;(current as unknown as ClientInternals).client = { request, disconnect: vi.fn() } + it.each([COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, PROTOCOL_VERSION])( + 'delegates protocol %s inspection to inspectProcess', + async (protocolVersion) => { + const request = vi.fn(async () => ({ foregroundProcess: 'codex', hasChildProcesses: true })) + const current = createInspectionAdapter(protocolVersion, request) - await current.inspectProcess('sess-a') + await current.inspectProcess('sess-a') - expect(request).toHaveBeenCalledWith('inspectProcess', { sessionId: 'sess-a' }) + expect(request).toHaveBeenCalledWith('inspectProcess', { sessionId: 'sess-a' }) - current.dispose() - }) + current.dispose() + } + ) }) describe('serialize / revive', () => { @@ -1413,6 +1494,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { cwd: '/home/user', sessionId: 'idle-checkpoint' }) + setTimeoutSpy.mockClear() expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 10_000)).toBe(false) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 8d0cdf796fa..e977ed6fbe6 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -11,6 +11,7 @@ import { CODEX_SHELL_READY_TIMEOUT_MS } from './session' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, @@ -38,6 +39,7 @@ import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { isShellProcess } from '../../shared/agent-detection' import { resolveWslSessionContext } from './wsl-session-context' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' @@ -824,12 +826,16 @@ export class DaemonPtyAdapter implements IPtyProvider { } async hasChildProcesses(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return true + } return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id)) } - async inspectProcess( - id: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + async inspectProcess(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return { foregroundProcess: null, hasChildProcesses: true, unavailable: true } + } if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { // Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the // one call they do support instead of throwing, or completion detection stays dead until recreate. @@ -850,6 +856,9 @@ export class DaemonPtyAdapter implements IPtyProvider { } async getForegroundProcess(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return null + } try { const result = await this.client.request<{ foregroundProcess: string | null }>( 'getForegroundProcess', diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 0c66cf38d47..52b429dc809 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -7,6 +7,7 @@ import { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types' +import { MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES } from '../providers/pty-process-list-admission' type AdapterMock = DaemonPtyAdapter & { emitData: (id: string, data: string, sequenceChars?: number) => void @@ -150,6 +151,26 @@ it('rejects completion inspection when no daemon owns the session', async () => await expect(router.inspectProcess('unmapped-session')).rejects.toThrow('terminal_gone') }) +it('preserves unavailable inspection from the owning legacy daemon', async () => { + const legacy = createAdapter('legacy', ['legacy-session']) + vi.mocked(legacy.inspectProcess).mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + const router = new DaemonPtyRouter({ + current: createAdapter('current'), + legacy: [legacy] + }) + await router.discoverLegacySessions() + + await expect(router.inspectProcess('legacy-session')).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) +}) + describe('DaemonPtyRouter', () => { it('reports separate conservative resume and fresh-create boundaries', () => { const current = createAdapter( @@ -421,6 +442,17 @@ describe('DaemonPtyRouter', () => { await expect(router.listProcesses()).rejects.toThrow('legacy unavailable') }) + it('fails listProcesses closed when adapters amplify the aggregate listing', async () => { + const current = createAdapter( + 'current', + buildSessionIds('current', MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES) + ) + const legacy = createAdapter('legacy', ['legacy-over-cap']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + await expect(router.listProcesses()).rejects.toThrow('pty_process_list_capacity') + }) + it('merges startup reconciliation and updates route mappings', async () => { const current = createAdapter('current', [], { alive: ['current-alive'], diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index e30021a6f0f..9ad2584dc94 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -8,6 +8,11 @@ import type { PtySpawnResult } from '../providers/types' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import { + collectPtyProcessListings, + PtyProcessListAdmission +} from '../providers/pty-process-list-admission' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -49,10 +54,12 @@ export class DaemonPtyRouter implements IPtyProvider { } async discoverLegacySessions(): Promise { + const admission = new PtyProcessListAdmission() for (const adapter of this.legacy) { try { const sessions = await adapter.listProcesses() - for (const session of sessions) { + for (const rawSession of sessions) { + const session = admission.admit(rawSession) this.sessionAdapters.set(session.id, adapter) } } catch (error) { @@ -189,9 +196,7 @@ export class DaemonPtyRouter implements IPtyProvider { return this.adapterFor(id).getForegroundProcess(id) } - async inspectProcess( - id: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + async inspectProcess(id: string): Promise { return this.adapterForInspection(id).inspectProcess(id) } @@ -210,10 +215,9 @@ export class DaemonPtyRouter implements IPtyProvider { async listProcesses(opts?: { deadlineMs?: number }): Promise { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. - const results = await Promise.all( - this.allAdapters().map((adapter) => adapter.listProcesses(opts)) + return await collectPtyProcessListings(this.allAdapters(), (adapter) => + adapter.listProcesses(opts) ) - return results.flat() } async getDefaultShell(): Promise { diff --git a/src/main/daemon/daemon-pty-size.ts b/src/main/daemon/daemon-pty-size.ts index 13f8dfe2bc3..2a4b02e96a7 100644 --- a/src/main/daemon/daemon-pty-size.ts +++ b/src/main/daemon/daemon-pty-size.ts @@ -1,13 +1,9 @@ -const DEFAULT_COLS = 80 -const DEFAULT_ROWS = 24 +import { isValidTerminalSize, normalizeTerminalSize } from '../../shared/terminal-size-limits' export function isValidPtySize(cols: number, rows: number): boolean { - return Number.isFinite(cols) && Number.isFinite(rows) && cols >= 1 && rows >= 1 + return isValidTerminalSize(cols, rows) } export function normalizePtySize(cols: number, rows: number): { cols: number; rows: number } { - if (isValidPtySize(cols, rows)) { - return { cols, rows } - } - return { cols: DEFAULT_COLS, rows: DEFAULT_ROWS } + return normalizeTerminalSize(cols, rows) } diff --git a/src/main/daemon/daemon-response-admission.ts b/src/main/daemon/daemon-response-admission.ts new file mode 100644 index 00000000000..815b5f6fb6c --- /dev/null +++ b/src/main/daemon/daemon-response-admission.ts @@ -0,0 +1,20 @@ +import type { DaemonRequest } from './types' + +export const DAEMON_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 +export const DAEMON_RESPONSE_RESERVATION_BYTES = DAEMON_MAX_RESPONSE_BYTES +export const DAEMON_MAX_ACTIVE_RESPONSE_BYTES = 128 * 1024 * 1024 +export const DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT = 64 * 1024 * 1024 +export const DAEMON_CONTROL_PROCESS_MAX_BUFFERED_BYTES = 64 * 1024 * 1024 + +const REQUESTS_WITH_POTENTIALLY_LARGE_RESULTS = new Set([ + 'createOrAttach', + 'getSnapshot', + 'listSessions', + 'takePendingOutput' +]) + +export function daemonResponseReservationBytes(request: DaemonRequest): number { + return REQUESTS_WITH_POTENTIALLY_LARGE_RESULTS.has(request.type) + ? DAEMON_RESPONSE_RESERVATION_BYTES + : 0 +} diff --git a/src/main/daemon/daemon-server-admission.test.ts b/src/main/daemon/daemon-server-admission.test.ts new file mode 100644 index 00000000000..c7ec4b8d9c0 --- /dev/null +++ b/src/main/daemon/daemon-server-admission.test.ts @@ -0,0 +1,354 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect, type Socket } from 'node:net' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT, + DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT, + DAEMON_MAX_CONTROL_CLIENTS, + DAEMON_MAX_STREAM_ATTACHMENTS, + DAEMON_MAX_TRANSPORT_SOCKETS +} from './daemon-admission-limits' +import { + DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT, + DAEMON_RESPONSE_RESERVATION_BYTES +} from './daemon-response-admission' +import { DaemonClient } from './client' +import { DaemonServer } from './daemon-server' +import { encodeNdjson } from './ndjson' +import { getDaemonSocketPath } from './daemon-spawner' +import { PROTOCOL_VERSION, type DaemonRequest } from './types' + +type ConnectedClientState = { + clientId: string + controlSocket: Socket + streamSocket: Socket | null + activeRequestCount: number + activeRequestBytes: number + activeResponseBytes: number +} + +type DaemonServerAdmissionState = { + transportSockets: Set + clients: Map + activeRequestCount: number + activeRequestBytes: number + activeResponseBytes: number + pendingPtySpawnPreparations: Map> + dispatchRequest(socket: Socket, clientId: string, value: unknown, lineBytes: number): void +} + +function readJsonLine(socket: Socket): Promise> { + return new Promise((resolve, reject) => { + let buffered = '' + const cleanup = (): void => { + socket.off('data', onData) + socket.off('error', onError) + socket.off('close', onClose) + } + const onData = (chunk: Buffer): void => { + buffered += chunk.toString('utf8') + const newline = buffered.indexOf('\n') + if (newline === -1) { + return + } + cleanup() + resolve(JSON.parse(buffered.slice(0, newline)) as Record) + } + const onError = (error: Error): void => { + cleanup() + reject(error) + } + const onClose = (): void => { + cleanup() + reject(new Error('Socket closed before a response')) + } + socket.on('data', onData) + socket.on('error', onError) + socket.on('close', onClose) + }) +} + +describe('DaemonServer admission', () => { + let directory: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let client: DaemonClient | null + let sockets: Socket[] + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'daemon-admission-test-')) + socketPath = getDaemonSocketPath(directory) + tokenPath = join(directory, 'daemon.token') + client = null + sockets = [] + }) + + afterEach(async () => { + client?.disconnect() + for (const socket of sockets) { + socket.destroy() + } + await server?.shutdown() + rmSync(directory, { recursive: true, force: true }) + }) + + async function startServer( + preparePtySpawn?: () => Promise, + ptySpawnHealthCheck?: () => Promise + ): Promise { + server = new DaemonServer({ + socketPath, + tokenPath, + ...(preparePtySpawn ? { preparePtySpawn } : {}), + ...(ptySpawnHealthCheck ? { ptySpawnHealthCheck } : {}), + spawnSubprocess: () => { + throw new Error('Test unexpectedly spawned a subprocess') + } + }) + await server.start() + } + + async function openSocket(): Promise { + const socket = connect(socketPath) + sockets.push(socket) + await new Promise((resolve, reject) => { + socket.once('connect', resolve) + socket.once('error', reject) + }) + return socket + } + + async function hello( + role: 'control' | 'stream', + clientId: string + ): Promise<{ socket: Socket; response: Record }> { + const socket = await openSocket() + const responsePromise = readJsonLine(socket) + socket.write( + encodeNdjson({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId, + role + }) + ) + return { socket, response: await responsePromise } + } + + async function waitFor(predicate: () => boolean): Promise { + await vi.waitFor(() => expect(predicate()).toBe(true)) + } + + it('bounds pre-auth transports and releases capacity after disconnect', async () => { + await startServer() + const state = server as unknown as DaemonServerAdmissionState + + await Promise.all(Array.from({ length: DAEMON_MAX_TRANSPORT_SOCKETS }, () => openSocket())) + await waitFor(() => state.transportSockets.size === DAEMON_MAX_TRANSPORT_SOCKETS) + + const overflow = await openSocket() + await waitFor(() => overflow.destroyed) + expect(state.transportSockets.size).toBe(DAEMON_MAX_TRANSPORT_SOCKETS) + + sockets[0].destroy() + await waitFor(() => state.transportSockets.size === DAEMON_MAX_TRANSPORT_SOCKETS - 1) + await openSocket() + await waitFor(() => state.transportSockets.size === DAEMON_MAX_TRANSPORT_SOCKETS) + }) + + it('bounds control clients and stream attachments, then reuses released slots', async () => { + await startServer() + const state = server as unknown as DaemonServerAdmissionState + const controls: Socket[] = [] + + for (let index = 0; index < DAEMON_MAX_CONTROL_CLIENTS; index += 1) { + const result = await hello('control', `client-${index}`) + expect(result.response.ok).toBe(true) + controls.push(result.socket) + } + await expect(hello('control', 'control-overflow')).resolves.toMatchObject({ + response: { ok: false, retryable: true, error: expect.stringContaining('control-client') } + }) + expect(state.clients.size).toBe(DAEMON_MAX_CONTROL_CLIENTS) + + for (let index = 0; index < DAEMON_MAX_STREAM_ATTACHMENTS; index += 1) { + await expect(hello('stream', `client-${index}`)).resolves.toMatchObject({ + response: { ok: true } + }) + } + await expect(hello('stream', `client-${DAEMON_MAX_STREAM_ATTACHMENTS}`)).resolves.toMatchObject( + { + response: { + ok: false, + retryable: true, + error: expect.stringContaining('stream-attachment') + } + } + ) + + controls[0].destroy() + await waitFor(() => state.clients.size === DAEMON_MAX_CONTROL_CLIENTS - 1) + await expect(hello('control', 'replacement-control')).resolves.toMatchObject({ + response: { ok: true } + }) + await expect(hello('stream', `client-${DAEMON_MAX_STREAM_ATTACHMENTS}`)).resolves.toMatchObject( + { + response: { ok: true } + } + ) + }) + + it('bounds concurrent response construction and cleans ownership after disconnect', async () => { + let finishPreparation!: () => void + const preparation = new Promise((resolve) => { + finishPreparation = resolve + }) + const preparePtySpawn = vi.fn(() => preparation) + await startServer(preparePtySpawn) + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const state = server as unknown as DaemonServerAdmissionState + + const maxLargeResponses = + DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT / DAEMON_RESPONSE_RESERVATION_BYTES + const pending = Array.from({ length: maxLargeResponses }, (_, index) => + client! + .request('createOrAttach', { + sessionId: `pending-${index}`, + cols: 80, + rows: 24 + }) + .catch((error: unknown) => error) + ) + await waitFor(() => preparePtySpawn.mock.calls.length === maxLargeResponses) + await expect( + client.request('createOrAttach', { + sessionId: 'request-overflow', + cols: 80, + rows: 24 + }) + ).rejects.toThrow('request capacity exceeded') + expect(state.activeRequestCount).toBe(maxLargeResponses) + expect(state.activeResponseBytes).toBe(DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT) + expect(state.pendingPtySpawnPreparations.size).toBe(maxLargeResponses) + + client.disconnect() + finishPreparation() + await Promise.all(pending) + await waitFor( + () => + state.activeRequestCount === 0 && + state.activeRequestBytes === 0 && + state.activeResponseBytes === 0 && + state.pendingPtySpawnPreparations.size === 0 + ) + }) + + it('keeps the independent active-request cap for small response methods', async () => { + let finishHealthCheck!: () => void + const healthCheck = new Promise((resolve) => { + finishHealthCheck = resolve + }) + const ptySpawnHealthCheck = vi.fn(() => healthCheck) + await startServer(undefined, ptySpawnHealthCheck) + const { socket, response } = await hello('control', 'active-request-cap-client') + expect(response.ok).toBe(true) + const state = server as unknown as DaemonServerAdmissionState + + for (let index = 0; index < DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT; index += 1) { + socket.write( + encodeNdjson({ id: `pending-${index}`, type: 'ptySpawnHealth', payload: undefined }) + ) + } + await waitFor( + () => ptySpawnHealthCheck.mock.calls.length === DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT + ) + const overflowResponse = readJsonLine(socket) + socket.write(encodeNdjson({ id: 'overflow', type: 'ptySpawnHealth', payload: undefined })) + await expect(overflowResponse).resolves.toMatchObject({ + id: 'overflow', + ok: false, + error: expect.stringContaining('request capacity exceeded') + }) + + socket.destroy() + finishHealthCheck() + await waitFor(() => state.activeRequestCount === 0 && state.activeRequestBytes === 0) + }) + + it('enforces the per-client retained-byte budget independently of request count', async () => { + let finishPreparation!: () => void + const preparation = new Promise((resolve) => { + finishPreparation = resolve + }) + const preparePtySpawn = vi.fn(() => preparation) + await startServer(preparePtySpawn) + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const state = server as unknown as DaemonServerAdmissionState + const connected = [...state.clients.values()][0] + const request = (id: string): DaemonRequest => ({ + id, + type: 'createOrAttach', + payload: { sessionId: id, cols: 80, rows: 24 } + }) + + state.dispatchRequest( + connected.controlSocket, + connected.clientId, + request('within-byte-budget'), + DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT - 1024 + ) + await waitFor(() => preparePtySpawn.mock.calls.length === 1) + state.dispatchRequest( + connected.controlSocket, + connected.clientId, + request('over-byte-budget'), + 2048 + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(preparePtySpawn).toHaveBeenCalledOnce() + expect(connected.activeRequestCount).toBe(1) + + client.disconnect() + finishPreparation() + await waitFor(() => state.activeRequestCount === 0 && state.activeRequestBytes === 0) + }) + + it('holds idle retirement until admitted work releases after disconnect', async () => { + let finishHealthCheck!: () => void + const healthCheck = new Promise((resolve) => { + finishHealthCheck = resolve + }) + const ptySpawnHealthCheck = vi.fn(() => healthCheck) + const onIdleShutdown = vi.fn() + server = new DaemonServer({ + socketPath, + tokenPath, + ptySpawnHealthCheck, + onIdleShutdown, + spawnSubprocess: () => { + throw new Error('Test unexpectedly spawned a subprocess') + } + }) + await server.start() + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const state = server as unknown as DaemonServerAdmissionState + + const pending = client.request('ptySpawnHealth', undefined).catch((error: unknown) => error) + await waitFor(() => ptySpawnHealthCheck.mock.calls.length === 1) + client.disconnect() + await pending + await waitFor(() => state.transportSockets.size === 0) + expect(state.activeRequestCount).toBe(1) + expect(onIdleShutdown).not.toHaveBeenCalled() + + finishHealthCheck() + await waitFor(() => state.activeRequestCount === 0 && onIdleShutdown.mock.calls.length === 1) + }) +}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index f55809a131a..5c38c4d2837 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -4,7 +4,12 @@ import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' import { writeFileSync, chmodSync } from 'node:fs' import { StringDecoder } from 'node:string_decoder' -import { encodeNdjson, createNdjsonParser } from './ndjson' +import { + DAEMON_HANDSHAKE_MAX_LINE_BYTES, + encodeBoundedNdjson, + encodeNdjson, + createNdjsonParser +} from './ndjson' import { TerminalHost } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import { @@ -36,6 +41,27 @@ import { isAgentSessionExecutionClaim, isAgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' +import { + DAEMON_CONTROL_SOCKET_MAX_BUFFERED_BYTES, + DAEMON_HANDSHAKE_TIMEOUT_MS, + DAEMON_MAX_ACTIVE_REQUEST_BYTES, + DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT, + DAEMON_MAX_ACTIVE_REQUESTS, + DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT, + DAEMON_MAX_CONTROL_CLIENTS, + DAEMON_MAX_STREAM_ATTACHMENTS, + DAEMON_MAX_TRANSPORT_SOCKETS, + daemonHelloAdmissionError, + daemonRequestAdmissionError, + getBoundedDaemonRequestId +} from './daemon-admission-limits' +import { + DAEMON_CONTROL_PROCESS_MAX_BUFFERED_BYTES, + DAEMON_MAX_ACTIVE_RESPONSE_BYTES, + DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT, + DAEMON_MAX_RESPONSE_BYTES, + daemonResponseReservationBytes +} from './daemon-response-admission' export type DaemonServerOptions = { socketPath: string @@ -77,6 +103,9 @@ type ConnectedClient = { controlSocket: Socket streamSocket: Socket | null authenticatedPairEstablished: boolean + activeRequestCount: number + activeRequestBytes: number + activeResponseBytes: number } type PendingPtySpawnPreparation = { @@ -109,6 +138,9 @@ export class DaemonServer { private preparePtySpawn: () => Promise private log: DaemonFileLog private transportSockets = new Set() + private activeRequestCount = 0 + private activeRequestBytes = 0 + private activeResponseBytes = 0 private createOrAttachInFlight = 0 private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' private initialAdoptionTimer: unknown | null = null @@ -304,6 +336,7 @@ export class DaemonServer { return ( this.transportSockets.size === 0 && this.clients.size === 0 && + this.activeRequestCount === 0 && this.createOrAttachInFlight === 0 && this.host.listSessions().length === 0 ) @@ -382,6 +415,12 @@ export class DaemonServer { } private handleConnection(socket: Socket): void { + socket.on('error', () => socket.destroy()) + if (this.transportSockets.size >= DAEMON_MAX_TRANSPORT_SOCKETS) { + socket.destroy() + return + } + this.cancelInitialAdoptionTimer() this.transportSockets.add(socket) const removeTransport = (): void => { @@ -389,7 +428,6 @@ export class DaemonServer { this.reevaluateIdleShutdown() } socket.once('close', removeTransport) - socket.on('error', () => socket.destroy()) if (this.idleShutdownState !== 'running') { // Why: a connection accepted just before close() gets an explicit retry signal instead of dying mid-auth. @@ -403,45 +441,67 @@ export class DaemonServer { ) return } + + let handshakeComplete = false + const handshakeTimer = setTimeout(() => socket.destroy(), DAEMON_HANDSHAKE_TIMEOUT_MS) + handshakeTimer.unref() + socket.once('close', () => clearTimeout(handshakeTimer)) // Why: keep UTF-8 sequences intact across socket chunks before NDJSON parsing. const decoder = new StringDecoder('utf8') const parser = createNdjsonParser( - (msg) => this.handleFirstMessage(socket, msg, parser), + (msg) => { + if (handshakeComplete) { + return + } + handshakeComplete = true + clearTimeout(handshakeTimer) + this.handleFirstMessage(socket, msg) + }, () => { socket.destroy() - } + }, + { maxLineBytes: DAEMON_HANDSHAKE_MAX_LINE_BYTES } ) socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) } - private handleFirstMessage( - socket: Socket, - msg: unknown, - _parser: ReturnType - ): void { - const hello = msg as HelloMessage - if (hello.type !== 'hello') { - this.log.log('client-hello-rejected', { reason: 'expected-hello' }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) - socket.destroy() + private handleFirstMessage(socket: Socket, msg: unknown): void { + const helloError = daemonHelloAdmissionError(msg) + if (helloError) { + this.log.log('client-hello-rejected', { reason: 'invalid-hello' }) + this.rejectHello(socket, helloError) return } + const hello = msg as HelloMessage if (hello.version !== this.protocolVersion) { this.log.log('client-hello-rejected', { reason: 'protocol-mismatch', clientVersion: hello.version }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) - socket.destroy() + this.rejectHello(socket, 'Protocol version mismatch') return } if (hello.token !== this.token) { this.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) - socket.destroy() + this.rejectHello(socket, 'Invalid token') + return + } + + const previous = hello.role === 'control' ? this.clients.get(hello.clientId) : undefined + const streamClient = hello.role === 'stream' ? this.clients.get(hello.clientId) : undefined + if (hello.role === 'control' && !previous && this.clients.size >= DAEMON_MAX_CONTROL_CLIENTS) { + this.rejectHello(socket, 'Daemon control-client capacity exceeded; reconnect', true) + return + } + if ( + streamClient && + streamClient.streamSocket === null && + this.streamAttachmentCount() >= DAEMON_MAX_STREAM_ATTACHMENTS + ) { + this.rejectHello(socket, 'Daemon stream-attachment capacity exceeded; reconnect', true) return } @@ -463,12 +523,14 @@ export class DaemonServer { ) if (hello.role === 'control') { - const previous = this.clients.get(hello.clientId) const client: ConnectedClient = { clientId: hello.clientId, controlSocket: socket, streamSocket: null, - authenticatedPairEstablished: false + authenticatedPairEstablished: false, + activeRequestCount: 0, + activeRequestBytes: 0, + activeResponseBytes: 0 } this.clients.set(hello.clientId, client) this.setupControlSocket(socket, hello.clientId) @@ -480,30 +542,50 @@ export class DaemonServer { previous.streamSocket?.destroy() previous.controlSocket.destroy() } - } else if (hello.role === 'stream') { - const client = this.clients.get(hello.clientId) - if (!client) { - // Why: a stream socket is meaningless without its control socket; drop the orphan. - socket.destroy() - return - } - this.setupStreamSocket(socket, client) - client.authenticatedPairEstablished = true + } else if (streamClient) { + this.setupStreamSocket(socket, streamClient) + streamClient.authenticatedPairEstablished = true // Why: one-shot health probes authenticate only a control socket; they are not fresh app activity. this.onAuthenticatedClientPair() // A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement. this.initialAdoptionDeadlineMs = null this.retirementRequested = false this.cancelInitialAdoptionTimer() + } else { + // Why: preserve the legacy authenticated handshake before dropping a stream with no owner. + socket.destroy() } } + private rejectHello(socket: Socket, error: string, retryable = false): void { + socket.end( + encodeNdjson({ + type: 'hello', + ok: false, + error, + ...(retryable ? { retryable: true } : {}) + }), + () => socket.destroy() + ) + } + + private streamAttachmentCount(): number { + let count = 0 + for (const client of this.clients.values()) { + if (client.streamSocket) { + count += 1 + } + } + return count + } + private setupControlSocket(socket: Socket, clientId: string): void { // Why: decode as a UTF-8 stream so emoji/Unicode split across chunks isn't corrupted. const decoder = new StringDecoder('utf8') const parser = createNdjsonParser( - (msg) => this.handleRequest(socket, clientId, msg as DaemonRequest), - () => {} // Ignore parse errors + (msg, lineBytes) => this.dispatchRequest(socket, clientId, msg, lineBytes ?? 0), + () => socket.destroy(), + { includeLineBytes: true } ) // Remove the initial data listener and replace with the RPC parser @@ -569,6 +651,86 @@ export class DaemonServer { } } + private dispatchRequest( + socket: Socket, + clientId: string, + value: unknown, + lineBytes: number + ): void { + const client = this.clients.get(clientId) + if (!client || client.controlSocket !== socket) { + return + } + + const requestId = getBoundedDaemonRequestId(value) + const admissionError = daemonRequestAdmissionError(value) + if (admissionError) { + this.writeRequestError(socket, requestId, admissionError) + return + } + const request = value as DaemonRequest + const responseReservationBytes = daemonResponseReservationBytes(request) + if ( + client.activeRequestCount >= DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT || + this.activeRequestCount >= DAEMON_MAX_ACTIVE_REQUESTS || + client.activeRequestBytes + lineBytes > DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT || + this.activeRequestBytes + lineBytes > DAEMON_MAX_ACTIVE_REQUEST_BYTES || + client.activeResponseBytes + responseReservationBytes > + DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT || + this.activeResponseBytes + responseReservationBytes > DAEMON_MAX_ACTIVE_RESPONSE_BYTES + ) { + this.writeRequestError(socket, request.id, 'Daemon request capacity exceeded; retry') + return + } + + client.activeRequestCount += 1 + client.activeRequestBytes += lineBytes + this.activeRequestCount += 1 + this.activeRequestBytes += lineBytes + client.activeResponseBytes += responseReservationBytes + this.activeResponseBytes += responseReservationBytes + void this.handleRequest(socket, clientId, request).finally(() => { + client.activeRequestCount -= 1 + client.activeRequestBytes -= lineBytes + this.activeRequestCount -= 1 + this.activeRequestBytes -= lineBytes + client.activeResponseBytes -= responseReservationBytes + this.activeResponseBytes -= responseReservationBytes + this.reevaluateIdleShutdown() + }) + } + + private writeRequestError(socket: Socket, requestId: string | null, error: string): void { + if (!requestId || requestId.startsWith(NOTIFY_PREFIX)) { + return + } + this.writeControlMessage(socket, { id: requestId, ok: false, error }) + } + + private writeControlMessage(socket: Socket, message: unknown, onFlushed?: () => void): void { + const encoded = encodeBoundedNdjson(message, DAEMON_MAX_RESPONSE_BYTES) + const encodedBytes = Buffer.byteLength(encoded, 'utf8') + if ( + socket.writableLength + encodedBytes > DAEMON_CONTROL_SOCKET_MAX_BUFFERED_BYTES || + this.controlSocketBufferedBytes() + encodedBytes > DAEMON_CONTROL_PROCESS_MAX_BUFFERED_BYTES + ) { + socket.destroy() + return + } + socket.write(encoded, onFlushed) + } + + private controlSocketBufferedBytes(): number { + let total = 0 + for (const client of this.clients.values()) { + total += Math.max(0, client.controlSocket.writableLength) + if (total > DAEMON_CONTROL_PROCESS_MAX_BUFFERED_BYTES) { + break + } + } + return total + } + private async handleRequest( socket: Socket, clientId: string, @@ -582,19 +744,17 @@ export class DaemonServer { const pendingShutdown = this.pendingShutdownReplies.get( this.shutdownReplyKey(clientId, request.id) ) - socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => { + this.writeControlMessage(socket, { id: request.id, ok: true, payload: result }, () => { pendingShutdown?.start() }) } } catch (err) { if (!isNotify) { - socket.write( - encodeNdjson({ - id: request.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }) - ) + this.writeControlMessage(socket, { + id: request.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }) } } } @@ -759,7 +919,6 @@ export class DaemonServer { sessionIdSuffix: routedSessionId.slice(-10) }) this.transientFactRelay.onSessionExit(routedSessionId) - this.streamDataBatcher.refreshSessionDroppability(routedSessionId) this.streamClientIdBySessionId.delete(routedSessionId) this.lastInputAtBySessionId.delete(routedSessionId) this.reevaluateIdleShutdown() @@ -772,7 +931,6 @@ export class DaemonServer { } routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? p.sessionId this.streamClientIdBySessionId.set(routedSessionId, clientId) - this.streamDataBatcher.refreshSessionDroppability(routedSessionId) // Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head. if (this.transientFactRelay.isBackgrounded(routedSessionId)) { this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { @@ -847,12 +1005,7 @@ export class DaemonServer { sessionIdSuffix: sessionId.slice(-10), background }) - const backgroundChanged = this.transientFactRelay.setSessionBackground( - sessionId, - background - ) - this.streamDataBatcher.refreshSessionDroppability(sessionId) - if (!backgroundChanged) { + if (!this.transientFactRelay.setSessionBackground(sessionId, background)) { return {} } if (background) { @@ -937,6 +1090,7 @@ export class DaemonServer { authenticatedClient !== undefined && authenticatedClient.streamSocket !== null && this.clients.size === 1 && + this.activeRequestCount <= 1 && this.createOrAttachInFlight === 0 && this.host.listSessions().length === 0 && [...this.transportSockets].every( diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index ba0ea239c67..2a900d03cde 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -1,7 +1,8 @@ import { createHash, randomUUID } from 'node:crypto' -import { constants, copyFileSync, existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs' +import { constants, copyFileSync, existsSync, renameSync, unlinkSync } from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' +import { readDaemonControlFileText } from './daemon-control-file-reader' export type DaemonConnectionInfo = { socketPath: string @@ -141,7 +142,7 @@ function claimAndUnlinkOwnedFile( return false } try { - if (ownsContent(readFileSync(claimedPath, 'utf8'))) { + if (ownsContent(readDaemonControlFileText(claimedPath))) { unlinkSync(claimedPath) return true } diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 53e17002179..83e1b6c0c31 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -6,13 +6,14 @@ import { encodeStreamDataEvent, writeStreamDataEvents } from './daemon-stream-data-split' -import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' +import { + backgroundSessionDropCapChars, + backgroundSessionKeepTailChars, + dropOldestQueuedForSession, + type PendingStreamDataBatch +} from './daemon-stream-keep-tail-drop' import type { DaemonEvent } from './types' import { appendDaemonStreamData, type DaemonStreamEnqueueOptions } from './daemon-stream-data-entry' -import { - evaluateDroppableEnqueue, - refreshDroppableSessionMembership -} from './daemon-stream-droppable-membership' type StreamDataClient = { streamSocket: Socket | null @@ -73,16 +74,30 @@ export class DaemonStreamDataBatcher { } const batch = this.getOrCreateBatch(clientId) - const queuedAfter = appendDaemonStreamData(batch, sessionId, data, options) - const queuedBefore = queuedAfter - data.length - evaluateDroppableEnqueue( - batch, - sessionId, - queuedBefore, - queuedAfter, - this.isSessionDroppable, - this.salvageDroppedData - ) + appendDaemonStreamData(batch, sessionId, data, options) + + if (this.isSessionDroppable(sessionId)) { + // Keep-tail scales down as more backgrounded sessions queue, bounding the aggregate a reveal must drain (see daemon-stream-keep-tail-drop). + const droppableQueued = this.countDroppableSessionsWithQueuedData(batch) + const dropCap = backgroundSessionDropCapChars(droppableQueued) + const keepTail = backgroundSessionKeepTailChars(droppableQueued) + if ((batch.queuedCharsBySession.get(sessionId) ?? 0) > dropCap) { + dropOldestQueuedForSession(batch, sessionId, keepTail, this.salvageDroppedData) + } + if (droppableQueued > (batch.lastDroppableSessionCount ?? 0)) { + // Shared budget tightened: re-trim sessions that already finished producing — they never re-enter this path on their own. + for (const [queuedSessionId, queued] of Array.from(batch.queuedCharsBySession)) { + if ( + queued > dropCap && + queuedSessionId !== sessionId && + this.isSessionDroppable(queuedSessionId) + ) { + dropOldestQueuedForSession(batch, queuedSessionId, keepTail, this.salvageDroppedData) + } + } + } + batch.lastDroppableSessionCount = droppableQueued + } if ( options.flushImmediately === true && @@ -110,21 +125,20 @@ export class DaemonStreamDataBatcher { } } - refreshSessionDroppability(sessionId: string): void { - const droppable = this.isSessionDroppable(sessionId) - refreshDroppableSessionMembership(this.pendingByClient.values(), sessionId, droppable) + private countDroppableSessionsWithQueuedData(batch: PendingStreamDataBatch): number { + let count = 0 + for (const [sessionId, queued] of batch.queuedCharsBySession) { + if (queued > 0 && this.isSessionDroppable(sessionId)) { + count++ + } + } + return count } private getOrCreateBatch(clientId: string): PendingStreamDataBatch { let batch = this.pendingByClient.get(clientId) if (!batch) { - batch = { - timer: null, - queue: [], - queuedChars: 0, - queuedCharsBySession: new Map(), - droppableQueuedSessionIds: new Set() - } + batch = { timer: null, queue: [], queuedChars: 0, queuedCharsBySession: new Map() } this.pendingByClient.set(clientId, batch) } return batch @@ -211,7 +225,6 @@ export class DaemonStreamDataBatcher { (batch.queuedCharsBySession.get(entry.sessionId) ?? slice.length) - slice.length if (sessionHeldAfter <= 0) { batch.queuedCharsBySession.delete(entry.sessionId) - batch.droppableQueuedSessionIds.delete(entry.sessionId) } else { batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter) } @@ -283,7 +296,6 @@ export class DaemonStreamDataBatcher { batch.queue = retained batch.queuedChars -= flushedChars batch.queuedCharsBySession.delete(sessionId) - batch.droppableQueuedSessionIds.delete(sessionId) if (batch.queue.length === 0) { if (batch.timer) { clearTimeout(batch.timer) diff --git a/src/main/daemon/daemon-stream-data-entry.ts b/src/main/daemon/daemon-stream-data-entry.ts index a07e6141874..6d44c6a49e8 100644 --- a/src/main/daemon/daemon-stream-data-entry.ts +++ b/src/main/daemon/daemon-stream-data-entry.ts @@ -13,7 +13,7 @@ export function appendDaemonStreamData( sessionId: string, data: string, options: DaemonStreamEnqueueOptions -): number { +): void { const last = batch.queue.at(-1) // Why: control and transformed spans mark indivisible source-stream positions. if ( @@ -39,7 +39,8 @@ export function appendDaemonStreamData( }) } batch.queuedChars += data.length - const queuedAfter = (batch.queuedCharsBySession.get(sessionId) ?? 0) + data.length - batch.queuedCharsBySession.set(sessionId, queuedAfter) - return queuedAfter + batch.queuedCharsBySession.set( + sessionId, + (batch.queuedCharsBySession.get(sessionId) ?? 0) + data.length + ) } diff --git a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts deleted file mode 100644 index 76cdc72a990..00000000000 --- a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { randomUUID } from 'node:crypto' -import type { Socket } from 'node:net' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { DaemonServer } from './daemon-server' -import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' -import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' -import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' -import type { SubprocessHandle } from './session' -import type { DaemonRequest } from './types' - -type MockSubprocess = SubprocessHandle & { - emitData(data: string): void - emitExit(code: number): void -} - -type DaemonLifecyclePrivate = { - clients: Map< - string, - { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - authenticatedPairEstablished: boolean - } - > - host: { - getPartialEscapeTailAnsi(sessionId: string): string - } - routeRequest(clientId: string, request: DaemonRequest): Promise - streamDataBatcher: DaemonStreamDataBatcher - transientFactRelay: BackgroundTransientFactRelay - streamClientIdBySessionId: Map -} - -function createMockSubprocess(): MockSubprocess { - let onData: ((data: string) => void) | undefined - let onExit: ((code: number) => void) | undefined - return { - pid: 55_555, - getForegroundProcess: () => null, - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => onExit?.(0)), - forceKill: vi.fn(() => onExit?.(137)), - signal: vi.fn(), - onData(callback) { - onData = callback - }, - onExit(callback) { - onExit = callback - }, - dispose: vi.fn(), - emitData(data) { - onData?.(data) - }, - emitExit(code) { - onExit?.(code) - } - } -} - -function createServerHarness() { - const subprocesses: MockSubprocess[] = [] - const unique = randomUUID() - const server = new DaemonServer({ - socketPath: join(tmpdir(), `orca-droppability-${unique}.sock`), - tokenPath: join(tmpdir(), `orca-droppability-${unique}.token`), - spawnSubprocess: () => { - const subprocess = createMockSubprocess() - subprocesses.push(subprocess) - return subprocess - } - }) - return { - server, - daemon: server as unknown as DaemonLifecyclePrivate, - subprocesses - } -} - -function addClient( - daemon: DaemonLifecyclePrivate, - writableLength = 0 -): Socket & { write: ReturnType; writableLength: number } { - const controlSocket = { destroy: vi.fn() } as unknown as Socket - const streamSocket = { - destroyed: false, - writableLength, - destroy: vi.fn(), - write: vi.fn(() => true) - } as unknown as Socket & { - write: ReturnType - writableLength: number - } - daemon.clients.set('client-1', { - clientId: 'client-1', - controlSocket, - streamSocket, - authenticatedPairEstablished: true - }) - return streamSocket -} - -function pendingBatch(batcher: DaemonStreamDataBatcher): PendingStreamDataBatch { - const pending = ( - batcher as unknown as { - pendingByClient: Map - } - ).pendingByClient.get('client-1') - if (!pending) { - throw new Error('Missing client-1 stream batch') - } - return pending -} - -describe('daemon stream droppability lifecycle', () => { - let server: DaemonServer | undefined - - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(async () => { - await server?.shutdown() - vi.clearAllTimers() - vi.useRealTimers() - }) - - it('refreshes after every background mutation and before changed markers', async () => { - const harness = createServerHarness() - server = harness.server - const { daemon } = harness - addClient(daemon) - daemon.streamClientIdBySessionId.set('session-toggle', 'client-1') - vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('') - const lifecycle: string[] = [] - vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation( - (sessionId) => { - lifecycle.push(`refresh:${String(daemon.transientFactRelay.isBackgrounded(sessionId))}`) - } - ) - vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent').mockImplementation( - (_clientId, _sessionId, control) => { - lifecycle.push( - `marker:${String( - control.event === 'sessionBackgroundMarker' && control.payload.background - )}` - ) - } - ) - - await daemon.routeRequest('client-1', { - id: 'background', - type: 'setSessionBackground', - payload: { sessionId: 'session-toggle', background: true } - }) - expect(lifecycle).toEqual(['refresh:true', 'marker:true']) - - lifecycle.length = 0 - await daemon.routeRequest('client-1', { - id: 'duplicate-background', - type: 'setSessionBackground', - payload: { sessionId: 'session-toggle', background: true } - }) - expect(lifecycle).toEqual(['refresh:true']) - - lifecycle.length = 0 - await daemon.routeRequest('client-1', { - id: 'foreground', - type: 'setSessionBackground', - payload: { sessionId: 'session-toggle', background: false } - }) - expect(lifecycle).toEqual(['refresh:false', 'marker:false']) - }) - - it('routes an attached session before refresh and emits its background marker after', async () => { - const harness = createServerHarness() - server = harness.server - const { daemon } = harness - addClient(daemon) - daemon.transientFactRelay.setSessionBackground('session-attach', true) - const lifecycle: string[] = [] - vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation( - (sessionId) => { - lifecycle.push(`refresh:${daemon.streamClientIdBySessionId.get(sessionId) ?? 'unrouted'}`) - } - ) - vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent').mockImplementation( - (_clientId, _sessionId, control) => { - lifecycle.push(`marker:${control.event}`) - } - ) - - await daemon.routeRequest('client-1', { - id: 'attach', - type: 'createOrAttach', - payload: { sessionId: 'session-attach', cols: 80, rows: 24 } - }) - - expect(lifecycle).toEqual(['refresh:client-1', 'marker:sessionBackgroundMarker']) - }) - - it('invalidates droppable membership for final output held behind a deep socket', async () => { - const harness = createServerHarness() - server = harness.server - const { daemon, subprocesses } = harness - addClient(daemon, 128 * 1024) - daemon.transientFactRelay.setSessionBackground('session-exit', true) - - await daemon.routeRequest('client-1', { - id: 'attach', - type: 'createOrAttach', - payload: { sessionId: 'session-exit', cols: 80, rows: 24 } - }) - const subprocess = subprocesses[0] - subprocess.emitData('final-output'.repeat(1024)) - expect(pendingBatch(daemon.streamDataBatcher).droppableQueuedSessionIds).toContain( - 'session-exit' - ) - - subprocess.emitExit(42) - - const batch = pendingBatch(daemon.streamDataBatcher) - expect(batch.droppableQueuedSessionIds).not.toContain('session-exit') - expect(batch.queuedCharsBySession.get('session-exit')).toBeGreaterThan(0) - expect(batch.queue.at(-1)?.control).toMatchObject({ - event: 'exit', - payload: { code: 42 } - }) - expect(daemon.transientFactRelay.isBackgrounded('session-exit')).toBe(false) - expect(daemon.streamClientIdBySessionId.has('session-exit')).toBe(false) - }) -}) diff --git a/src/main/daemon/daemon-stream-droppable-membership.test.ts b/src/main/daemon/daemon-stream-droppable-membership.test.ts deleted file mode 100644 index 09da08e4a0f..00000000000 --- a/src/main/daemon/daemon-stream-droppable-membership.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Socket } from 'node:net' -import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' -import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' - -type TestSocket = Socket & { - write: ReturnType - writableLength: number -} - -function createSocket(): TestSocket { - return { - destroyed: false, - writableLength: 0, - write: vi.fn(() => true) - } as unknown as TestSocket -} - -function createBatcher(options?: ConstructorParameters[1]) { - const sockets = new Map() - const socketFor = (clientId: string): TestSocket => { - let socket = sockets.get(clientId) - if (!socket) { - socket = createSocket() - sockets.set(clientId, socket) - } - return socket - } - const batcher = new DaemonStreamDataBatcher( - (clientId) => ({ streamSocket: socketFor(clientId) }), - options - ) - return { batcher, socketFor } -} - -function pendingBatch( - batcher: DaemonStreamDataBatcher, - clientId = 'client-1' -): PendingStreamDataBatch { - const pendingByClient = ( - batcher as unknown as { - pendingByClient: Map - } - ).pendingByClient - const batch = pendingByClient.get(clientId) - if (!batch) { - throw new Error(`Missing pending batch for ${clientId}`) - } - return batch -} - -function pendingByClient(batcher: DaemonStreamDataBatcher): Map { - return ( - batcher as unknown as { - pendingByClient: Map - } - ).pendingByClient -} - -describe('DaemonStreamDataBatcher droppable membership', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.clearAllTimers() - vi.useRealTimers() - }) - - it('does no droppability scans for steady-state output from an existing member', () => { - const isSessionDroppable = vi.fn(() => true) - const { batcher } = createBatcher({ isSessionDroppable }) - const sessionCount = 100 - const chunkCount = 1_000 - - for (let index = 0; index < sessionCount; index++) { - batcher.enqueue('client-1', `session-${index}`, 'seed') - } - const batch = pendingBatch(batcher) - const mapGet = vi.spyOn(batch.queuedCharsBySession, 'get') - const mapSet = vi.spyOn(batch.queuedCharsBySession, 'set') - const mapIterator = vi.spyOn(batch.queuedCharsBySession, Symbol.iterator) - const mapEntries = vi.spyOn(batch.queuedCharsBySession, 'entries') - const mapValues = vi.spyOn(batch.queuedCharsBySession, 'values') - const mapForEach = vi.spyOn(batch.queuedCharsBySession, 'forEach') - const membershipHas = vi.spyOn(batch.droppableQueuedSessionIds, 'has') - isSessionDroppable.mockClear() - - for (let index = 0; index < chunkCount; index++) { - batcher.enqueue('client-1', 'session-0', 'x') - } - - // Legacy evaluated the producer plus all 100 queued sessions per chunk: 101,000 calls. - expect(isSessionDroppable).toHaveBeenCalledTimes(0) - expect(mapIterator).toHaveBeenCalledTimes(0) - expect(mapEntries).toHaveBeenCalledTimes(0) - expect(mapValues).toHaveBeenCalledTimes(0) - expect(mapForEach).toHaveBeenCalledTimes(0) - expect(mapGet).toHaveBeenCalledTimes(chunkCount) - expect(mapSet).toHaveBeenCalledTimes(chunkCount) - expect(membershipHas).toHaveBeenCalledTimes(chunkCount) - expect(batcher.queuedCharsForClient('client-1')).toBe(1_400) - expect(batch.queuedCharsBySession.get('session-0')).toBe(1_004) - expect(batch.droppableQueuedSessionIds).toHaveLength(sessionCount) - }) - - it('evaluates a new positive session exactly once and records membership', () => { - const isSessionDroppable = vi.fn(() => true) - const { batcher } = createBatcher({ isSessionDroppable }) - - batcher.enqueue('client-1', 'session-new', 'x') - - expect(isSessionDroppable).toHaveBeenCalledTimes(1) - expect(isSessionDroppable).toHaveBeenCalledWith('session-new') - expect(pendingBatch(batcher).droppableQueuedSessionIds).toEqual(new Set(['session-new'])) - }) - - it('evaluates a transformed zero span without adding it and preserves growth re-trimming', () => { - let droppable = false - const isSessionDroppable = vi.fn(() => droppable) - const { batcher } = createBatcher({ isSessionDroppable }) - const queued = 1_100 * 1024 - - batcher.enqueue('client-1', 'session-held', 'h'.repeat(queued)) - droppable = true - batcher.refreshSessionDroppability('session-held') - expect(batcher.queuedCharsForClient('client-1')).toBe(queued) - isSessionDroppable.mockClear() - - batcher.enqueue('client-1', 'session-empty', '', { - rawLength: 9, - transformed: true - }) - - const batch = pendingBatch(batcher) - expect(isSessionDroppable).toHaveBeenCalledTimes(1) - expect(batch.droppableQueuedSessionIds).toEqual(new Set(['session-held'])) - expect(batch.queuedCharsBySession.get('session-empty')).toBe(0) - expect(batch.lastEvaluatedDroppableSessionCount).toBe(1) - expect(batch.queuedCharsBySession.get('session-held')).toBe(512 * 1024) - - isSessionDroppable.mockClear() - batcher.enqueue('client-1', 'session-held', 'z') - expect(isSessionDroppable).toHaveBeenCalledTimes(0) - expect(batch.queuedCharsBySession.get('session-held')).toBe(512 * 1024 + 1) - expect(batch.queue.find((entry) => entry.control?.event === 'dataGap')?.control).toMatchObject({ - event: 'dataGap', - payload: { droppedChars: queued - 512 * 1024 } - }) - }) - - it('refreshes one session across every client batch with one predicate call', () => { - let droppable = false - const isSessionDroppable = vi.fn(() => droppable) - const { batcher } = createBatcher({ isSessionDroppable }) - - batcher.enqueue('client-1', 'session-routed', 'a') - batcher.enqueue('client-2', 'session-routed', 'b') - batcher.enqueue('client-3', 'session-routed', '', { transformed: true }) - droppable = true - isSessionDroppable.mockClear() - - batcher.refreshSessionDroppability('session-routed') - - expect(isSessionDroppable).toHaveBeenCalledTimes(1) - expect(pendingBatch(batcher, 'client-1').droppableQueuedSessionIds).toContain('session-routed') - expect(pendingBatch(batcher, 'client-2').droppableQueuedSessionIds).toContain('session-routed') - expect(pendingBatch(batcher, 'client-3').droppableQueuedSessionIds).not.toContain( - 'session-routed' - ) - }) - - it('preserves queued-session Map order when growth re-trims members', () => { - const salvageDroppedData = vi.fn((_dropped: string) => '') - const { batcher } = createBatcher({ - isSessionDroppable: () => true, - salvageDroppedData - }) - const queuedPerSession = 800 * 1024 - - // A zero total reserves the first Map position without joining the Set. - batcher.enqueue('client-1', 'session-a', '', { transformed: true }) - for (const id of ['b', 'c', 'd', 'e']) { - batcher.enqueue('client-1', `session-${id}`, id.repeat(queuedPerSession)) - } - batcher.enqueue('client-1', 'session-a', 'a'.repeat(queuedPerSession)) - salvageDroppedData.mockClear() - - // The sixth member tightens the cap below 800 KiB. Map order is A→E, - // while Set insertion order is B→E→A. - batcher.enqueue('client-1', 'session-f', 'f') - - expect( - salvageDroppedData.mock.calls - .map(([dropped]) => dropped) - .filter((dropped) => dropped.length > 0) - .map((dropped) => dropped[0]) - ).toEqual(['a', 'b', 'c', 'd', 'e']) - }) - - it('reconciles foreground transitions without eagerly changing queued bytes', () => { - let droppable = false - const isSessionDroppable = vi.fn(() => droppable) - const { batcher } = createBatcher({ isSessionDroppable }) - const initialChars = 1_100 * 1024 - - batcher.enqueue('client-1', 'session-toggle', 'x'.repeat(initialChars)) - droppable = true - batcher.refreshSessionDroppability('session-toggle') - expect(batcher.queuedCharsForClient('client-1')).toBe(initialChars) - - isSessionDroppable.mockClear() - batcher.enqueue('client-1', 'session-toggle', 'x') - expect(isSessionDroppable).toHaveBeenCalledTimes(0) - expect(pendingBatch(batcher).queuedCharsBySession.get('session-toggle')).toBe(512 * 1024) - - droppable = false - batcher.refreshSessionDroppability('session-toggle') - const foregroundChars = 512 * 1024 + 600 * 1024 - isSessionDroppable.mockClear() - batcher.enqueue('client-1', 'session-toggle', 'y'.repeat(600 * 1024)) - expect(isSessionDroppable).toHaveBeenCalledTimes(0) - expect(pendingBatch(batcher).queuedCharsBySession.get('session-toggle')).toBe(foregroundChars) - - droppable = true - batcher.refreshSessionDroppability('session-toggle') - expect(pendingBatch(batcher).queuedCharsBySession.get('session-toggle')).toBe(foregroundChars) - }) - - it('does not lower the last evaluated count during shrink and regrow refreshes', () => { - const backgrounded = new Set() - const { batcher } = createBatcher({ - isSessionDroppable: (sessionId) => backgrounded.has(sessionId) - }) - const queuedPerSession = 600 * 1024 - - for (let index = 0; index < 6; index++) { - const sessionId = `session-${index}` - backgrounded.add(sessionId) - batcher.enqueue('client-1', sessionId, 'x'.repeat(queuedPerSession)) - } - const batch = pendingBatch(batcher) - expect(batch.lastEvaluatedDroppableSessionCount).toBe(6) - - backgrounded.delete('session-0') - batcher.refreshSessionDroppability('session-0') - batcher.enqueue('client-1', 'session-0', 'x'.repeat(300 * 1024)) - backgrounded.add('session-0') - batcher.refreshSessionDroppability('session-0') - expect(batch.queuedCharsBySession.get('session-0')).toBe(900 * 1024) - expect(batch.lastEvaluatedDroppableSessionCount).toBe(6) - - batcher.enqueue('client-1', 'session-1', 'x') - expect(batch.queuedCharsBySession.get('session-0')).toBe(900 * 1024) - }) - - it('retains membership after a partial drain and removes it after the final drain', () => { - const { batcher, socketFor } = createBatcher({ isSessionDroppable: () => true }) - const socket = socketFor('client-1') - const refillCallbacks: (() => void)[] = [] - socket.write.mockImplementation((line: string, callback?: () => void) => { - if (callback) { - refillCallbacks.push(callback) - } else if ((JSON.parse(String(line)) as { payload?: { data?: string } }).payload?.data) { - socket.writableLength = 128 * 1024 - } - return true - }) - - batcher.enqueue('client-1', 'session-drain', 'x'.repeat(128 * 1024)) - batcher.flush('client-1') - - expect(pendingBatch(batcher).queuedCharsBySession.get('session-drain')).toBe(64 * 1024) - expect(pendingBatch(batcher).droppableQueuedSessionIds).toContain('session-drain') - expect(refillCallbacks).toHaveLength(1) - - socket.writableLength = 0 - refillCallbacks[0]() - expect(pendingByClient(batcher).has('client-1')).toBe(false) - }) - - it('removes only the flushed session membership during an immediate session flush', () => { - const { batcher } = createBatcher({ isSessionDroppable: () => true }) - batcher.enqueue('client-1', 'session-flushed', 'flush-me') - batcher.enqueue('client-1', 'session-retained', 'keep-me') - - batcher.enqueue('client-1', 'session-flushed', '', { - flushImmediately: true - }) - - const batch = pendingBatch(batcher) - expect(batch.queuedCharsBySession.has('session-flushed')).toBe(false) - expect(batch.droppableQueuedSessionIds).toEqual(new Set(['session-retained'])) - }) - - it('never evaluates or tracks control-only entries', () => { - const isSessionDroppable = vi.fn(() => true) - const { batcher } = createBatcher({ isSessionDroppable }) - - batcher.enqueueControlEvent('client-1', 'session-control', { - type: 'event', - event: 'sessionBackgroundMarker', - sessionId: 'session-control', - payload: { background: true } - }) - - const batch = pendingBatch(batcher) - expect(isSessionDroppable).toHaveBeenCalledTimes(0) - expect(batch.queuedCharsBySession.has('session-control')).toBe(false) - expect(batch.droppableQueuedSessionIds).toHaveLength(0) - expect(batch.lastEvaluatedDroppableSessionCount).toBeUndefined() - }) -}) diff --git a/src/main/daemon/daemon-stream-droppable-membership.ts b/src/main/daemon/daemon-stream-droppable-membership.ts deleted file mode 100644 index b75f88337c2..00000000000 --- a/src/main/daemon/daemon-stream-droppable-membership.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - backgroundSessionDropCapChars, - backgroundSessionKeepTailChars, - dropOldestQueuedForSession, - type PendingStreamDataBatch -} from './daemon-stream-keep-tail-drop' - -export function evaluateDroppableEnqueue( - batch: PendingStreamDataBatch, - sessionId: string, - queuedBefore: number, - queuedAfter: number, - isSessionDroppable: (sessionId: string) => boolean, - salvageDroppedData: (dropped: string) => string -): void { - let sessionDroppable: boolean - if (queuedBefore <= 0) { - sessionDroppable = isSessionDroppable(sessionId) - if (queuedAfter > 0 && sessionDroppable) { - batch.droppableQueuedSessionIds.add(sessionId) - } else { - batch.droppableQueuedSessionIds.delete(sessionId) - } - } else { - sessionDroppable = batch.droppableQueuedSessionIds.has(sessionId) - } - if (!sessionDroppable) { - return - } - - const droppableQueued = batch.droppableQueuedSessionIds.size - const dropCap = backgroundSessionDropCapChars(droppableQueued) - const keepTail = backgroundSessionKeepTailChars(droppableQueued) - if (queuedAfter > dropCap) { - dropOldestQueuedForSession(batch, sessionId, keepTail, salvageDroppedData) - } - if (droppableQueued > (batch.lastEvaluatedDroppableSessionCount ?? 0)) { - // Shared budget tightened, so producers that stopped enqueueing must also be re-trimmed. - for (const [queuedSessionId, queued] of Array.from(batch.queuedCharsBySession)) { - if ( - queued > dropCap && - queuedSessionId !== sessionId && - batch.droppableQueuedSessionIds.has(queuedSessionId) - ) { - dropOldestQueuedForSession(batch, queuedSessionId, keepTail, salvageDroppedData) - } - } - } - batch.lastEvaluatedDroppableSessionCount = droppableQueued -} - -export function refreshDroppableSessionMembership( - batches: Iterable, - sessionId: string, - droppable: boolean -): void { - for (const batch of batches) { - if ((batch.queuedCharsBySession.get(sessionId) ?? 0) > 0 && droppable) { - batch.droppableQueuedSessionIds.add(sessionId) - } else { - batch.droppableQueuedSessionIds.delete(sessionId) - } - } -} diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts index 5d39afeaf1a..0ef7bd0aab9 100644 --- a/src/main/daemon/daemon-stream-keep-tail-drop.ts +++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts @@ -33,13 +33,10 @@ export type PendingStreamDataBatch = { // Per-session held totals so the flush hold can spare small talkers // (echo/replies) from waiting behind other sessions' floods. queuedCharsBySession: Map - // Membership is reconciled when queued data first appears and on rare - // background lifecycle changes, keeping steady-state enqueue constant-time. - droppableQueuedSessionIds: Set // Last droppable-sessions-with-queued-data count seen by the keep-tail // logic: when it GROWS the shared budget tightens, and sessions that // finished producing must be re-trimmed (they will never re-enqueue). - lastEvaluatedDroppableSessionCount?: number + lastDroppableSessionCount?: number } // The keep-tail must comfortably cover a full TUI repaint (~cols×rows×SGR ≈ diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index e3bd38af6fd..47c5375bad4 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -4,6 +4,10 @@ import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' import type { IPtyProvider, PtyBackgroundStreamEvent } from '../providers/types' import type { PtyDataEvent, PtyProviderBufferSnapshot } from '../providers/types' import type { PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import { + collectPtyProcessListings, + PtyProcessListAdmission +} from '../providers/pty-process-list-admission' export class DegradedDaemonPtyProvider implements IPtyProvider { readonly routesFreshSpawnsToLocalProvider = true @@ -45,11 +49,12 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async discoverDaemonSessions(): Promise { + const admission = new PtyProcessListAdmission() for (const adapter of this.allDaemonAdapters()) { try { const sessions = await adapter.listProcesses() for (const session of sessions) { - this.sessionProviders.set(session.id, adapter) + this.sessionProviders.set(admission.admit(session).id, adapter) } } catch (error) { console.warn('[daemon] Failed to discover degraded daemon sessions', error) @@ -170,12 +175,8 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { await this.fallback.revive(state) } - async listProcesses(opts?: { deadlineMs?: number }): Promise { - const results = await Promise.all( - this.allProviders().map((provider) => provider.listProcesses(opts)) - ) - return results.flat() - } + listProcesses = (opts?: { deadlineMs?: number }): Promise => + collectPtyProcessListings(this.allProviders(), (provider) => provider.listProcesses(opts)) async getDefaultShell(): Promise { return this.fallback.getDefaultShell() diff --git a/src/main/daemon/headless-emulator-size-limits.test.ts b/src/main/daemon/headless-emulator-size-limits.test.ts new file mode 100644 index 00000000000..38e4f096cd6 --- /dev/null +++ b/src/main/daemon/headless-emulator-size-limits.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { MAX_TERMINAL_COLS } from '../../shared/terminal-size-limits' +import { HeadlessEmulator } from './headless-emulator' + +describe('HeadlessEmulator terminal size limits', () => { + it('uses safe defaults for oversized persisted construction and resize dimensions', () => { + const emulator = new HeadlessEmulator({ cols: MAX_TERMINAL_COLS + 1, rows: 24 }) + expect(emulator.getAppliedSize()).toEqual({ cols: 80, rows: 24 }) + + emulator.resize(80, Number.MAX_SAFE_INTEGER) + expect(emulator.getAppliedSize()).toEqual({ cols: 80, rows: 24 }) + emulator.dispose() + }) +}) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 7cd36fe80f3..4f3797e6f51 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -20,6 +20,7 @@ import { } from './terminal-view-attribute-responder' import type { TerminalSnapshot, TerminalModes } from './types' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import { normalizeTerminalSize } from '../../shared/terminal-size-limits' export type HeadlessEmulatorOptions = { cols: number @@ -69,6 +70,7 @@ export class HeadlessEmulator { private partialEscapeTail = '' constructor(opts: HeadlessEmulatorOptions) { + const size = normalizeTerminalSize(opts.cols, opts.rows) this.pathFlavor = opts.pathFlavor this.remotePosixFileUriAuthority = opts.remotePosixFileUriAuthority === true this.oscText = new TerminalOscCwdTitleScanner({ @@ -77,8 +79,8 @@ export class HeadlessEmulator { wslDistro: opts.wslDistro }) this.terminal = new Terminal({ - cols: opts.cols, - rows: opts.rows, + cols: size.cols, + rows: size.rows, scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK, allowProposedApi: true, logLevel: 'off', @@ -224,7 +226,8 @@ export class HeadlessEmulator { return } this.restoredOscLinks = [] - this.terminal.resize(cols, rows) + const size = normalizeTerminalSize(cols, rows) + this.terminal.resize(size.cols, size.rows) } // Why: these dims proxy the child's real size, so they stay stale on a dropped resize the renderer must detect. diff --git a/src/main/daemon/history-manager-memory.test.ts b/src/main/daemon/history-manager-memory.test.ts new file mode 100644 index 00000000000..c472cb3a55b --- /dev/null +++ b/src/main/daemon/history-manager-memory.test.ts @@ -0,0 +1,68 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getHistorySessionDirName } from './history-paths' +import { HistoryManager } from './history-manager' +import type { TerminalSnapshot } from './types' + +const directories: string[] = [] + +function createManager(): { directory: string; manager: HistoryManager } { + const directory = mkdtempSync(join(tmpdir(), 'orca-history-manager-memory-')) + directories.push(directory) + return { directory, manager: new HistoryManager(directory) } +} + +function snapshot(snapshotAnsi: string): TerminalSnapshot { + return { + snapshotAnsi, + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/workspace', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + }, + scrollbackLines: 0 + } +} + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('terminal history write memory limits', () => { + it('rejects an oversized escaped checkpoint before writing it', async () => { + const { directory, manager } = createManager() + await manager.openSession('checkpoint', { cwd: '/workspace', cols: 80, rows: 24 }) + + await manager.checkpoint('checkpoint', snapshot('\n'.repeat(9 * 1024 * 1024))) + + expect(manager.isSessionDisabled('checkpoint')).toBe(true) + expect( + existsSync(join(directory, getHistorySessionDirName('checkpoint'), 'checkpoint.json')) + ).toBe(false) + }) + + it('rejects oversized escaped metadata before writing it', async () => { + const { directory, manager } = createManager() + + await manager.openSession('metadata', { + cwd: '\n'.repeat(40 * 1024), + cols: 80, + rows: 24 + }) + + expect(manager.isSessionDisabled('metadata')).toBe(true) + expect(existsSync(join(directory, getHistorySessionDirName('metadata'), 'meta.json'))).toBe( + false + ) + }) +}) diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 1af27025182..da9d5b68e93 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -2,55 +2,39 @@ import { join } from 'node:path' import { mkdirSync, writeFileSync, - readFileSync, existsSync, rmSync, unlinkSync, - openSync, - closeSync, - readSync, - fstatSync, promises as fsPromises } from 'node:fs' import { getHistorySessionDirName } from './history-paths' import { - decodeLogHeader, - encodeLogBatch, + encodeLogBatchWithinLimit, encodeLogHeader, LOG_HEADER_BYTES } from './terminal-history-log' import type { PendingOutputRecord, TerminalCheckpointFile, TerminalSnapshot } from './types' +import { stringifyJsonWithinByteLimit } from '../../shared/memory-safety/node-bounded-json-stringify' +import { + TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES, + TERMINAL_HISTORY_LOG_MAX_BYTES +} from './terminal-history-file-limits' +import { + readTerminalHistorySessionMeta, + stringifyTerminalHistorySessionMeta, + type HistoryManagerOptions, + type OpenSessionOptions, + type SessionMeta +} from './terminal-history-session-metadata' +import { + resolveTerminalHistoryLogState, + type TerminalHistoryLogState +} from './terminal-history-log-state' -// Why 5MB: bounds cold-restore replay time and per-session disk; hitting the cap triggers one checkpoint that resets the log. -const LOG_MAX_BYTES = 5 * 1024 * 1024 +export type { HistoryManagerOptions, OpenSessionOptions, SessionMeta } -export type SessionMeta = { - cwd: string - cols: number - rows: number - startedAt: string - endedAt: string | null - exitCode: number | null -} - -export type OpenSessionOptions = { - cwd: string - cols: number - rows: number -} - -type SessionWriter = { +type SessionWriter = TerminalHistoryLogState & { dir: string - checkpointPath: string - logPath: string - /** Generation of the on-disk log header. Null until lazily resolved on first append after a warm registerWriter. */ - logGeneration: number | null - /** Current log file size. Null until lazily resolved alongside generation. */ - logBytes: number | null -} - -export type HistoryManagerOptions = { - onWriteError?: (sessionId: string, error: Error) => void } export class HistoryManager { @@ -78,7 +62,7 @@ export class HistoryManager { endedAt: null, exitCode: null } - writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2)) + writeFileSync(join(dir, 'meta.json'), stringifyTerminalHistorySessionMeta(meta)) // Why: clear stale recovery files (incl. legacy scrollback.bin) so a crash before the first checkpoint can't replay a prior session's content. const checkpointPath = join(dir, 'checkpoint.json') @@ -153,11 +137,15 @@ export class HistoryManager { return 'ok' } try { - this.resolveLogState(writer) - const batch = encodeLogBatch(seq, records) + resolveTerminalHistoryLogState(writer) // Why max(..., header): a fresh log's header (written below) must count toward the projected size or the cap overshoots. - const projectedBytes = Math.max(writer.logBytes ?? 0, LOG_HEADER_BYTES) + batch.length - if (projectedBytes > LOG_MAX_BYTES) { + const existingBytes = Math.max(writer.logBytes ?? 0, LOG_HEADER_BYTES) + const batch = encodeLogBatchWithinLimit( + seq, + records, + TERMINAL_HISTORY_LOG_MAX_BYTES - existingBytes + ) + if (!batch) { return 'needs-checkpoint' } if (writer.logBytes === 0) { @@ -192,7 +180,7 @@ export class HistoryManager { effectiveCwd = meta?.cwd ?? null } - this.resolveLogState(writer) + resolveTerminalHistoryLogState(writer) const generation = (writer.logGeneration ?? 0) + 1 const checkpointFile: TerminalCheckpointFile = { snapshotAnsi: snapshot.snapshotAnsi, @@ -207,7 +195,10 @@ export class HistoryManager { generation, checkpointedAt: new Date().toISOString() } - const data = JSON.stringify(checkpointFile) + const data = stringifyJsonWithinByteLimit( + checkpointFile, + TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + ).serialized // Why: tmp+rename is atomic (corrupt checkpoint > stale); async so a sync ~MB write can't stall IPC (worse under Windows AV). // The adapter's checkpointInFlight guard serializes checkpoints, so concurrent async writes can't collide on the fixed .tmp path. const tmpPath = `${writer.checkpointPath}.tmp` @@ -222,46 +213,6 @@ export class HistoryManager { } } - // Why: a warm registerWriter may attach to an existing log; read generation/size once so appends continue it, not clobber it. - private resolveLogState(writer: SessionWriter): void { - if (writer.logBytes !== null && writer.logGeneration !== null) { - return - } - let headerGeneration: number | null = null - let size = 0 - try { - const fd = openSync(writer.logPath, 'r') - try { - size = fstatSync(fd).size - const header = Buffer.alloc(LOG_HEADER_BYTES) - if (readSync(fd, header, 0, LOG_HEADER_BYTES, 0) === LOG_HEADER_BYTES) { - headerGeneration = decodeLogHeader(header) - } - } finally { - closeSync(fd) - } - } catch { - // Missing log file — fresh state below. - } - if (headerGeneration !== null) { - writer.logGeneration = headerGeneration - writer.logBytes = size - return - } - // Missing/unreadable header: logBytes = 0 makes the next append truncate-rewrite, so a garbage file can't be extended. - writer.logBytes = 0 - writer.logGeneration = this.readCheckpointGeneration(writer) ?? 0 - } - - private readCheckpointGeneration(writer: SessionWriter): number | null { - try { - const checkpoint = JSON.parse(readFileSync(writer.checkpointPath, 'utf-8')) - return typeof checkpoint.generation === 'number' ? checkpoint.generation : null - } catch { - return null - } - } - async closeSession(sessionId: string, exitCode: number): Promise { const writer = this.writers.get(sessionId) if (!writer) { @@ -306,7 +257,7 @@ export class HistoryManager { return null } try { - return JSON.parse(readFileSync(metaPath, 'utf-8')) + return readTerminalHistorySessionMeta(metaPath) } catch { return null } @@ -333,7 +284,7 @@ export class HistoryManager { private readMetaFromDir(dir: string): SessionMeta | null { const metaPath = join(dir, 'meta.json') try { - return JSON.parse(readFileSync(metaPath, 'utf-8')) + return readTerminalHistorySessionMeta(metaPath) } catch { return null } @@ -343,11 +294,11 @@ export class HistoryManager { const metaPath = join(dir, 'meta.json') let meta: SessionMeta try { - meta = JSON.parse(readFileSync(metaPath, 'utf-8')) + meta = readTerminalHistorySessionMeta(metaPath) } catch { return } Object.assign(meta, updates) - writeFileSync(metaPath, JSON.stringify(meta, null, 2)) + writeFileSync(metaPath, stringifyTerminalHistorySessionMeta(meta)) } } diff --git a/src/main/daemon/history-reader-memory.test.ts b/src/main/daemon/history-reader-memory.test.ts index cecb29b1bb8..36e86f5f413 100644 --- a/src/main/daemon/history-reader-memory.test.ts +++ b/src/main/daemon/history-reader-memory.test.ts @@ -9,7 +9,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { HistoryReader } from './history-reader' import { getHistorySessionDirName } from './history-paths' import { @@ -17,9 +17,10 @@ import { TERMINAL_HISTORY_LOG_MAX_BYTES, TERMINAL_HISTORY_META_MAX_BYTES } from './terminal-history-file-limits' -import { readTerminalHistoryJson } from './terminal-history-file-reader' - -const RETIRED_CHECKPOINT_READ_CAP_BYTES = 16 * 1024 * 1024 +import { + readTerminalHistoryJson, + TERMINAL_HISTORY_JSON_MAX_STRUCTURAL_TOKENS +} from './terminal-history-file-reader' const directories: string[] = [] @@ -48,7 +49,7 @@ function createSparseFile(path: string, bytes: number): void { closeSync(descriptor) } -function checkpoint(overrides?: Record): string { +function checkpoint(): string { return JSON.stringify({ snapshotAnsi: 'safe checkpoint', scrollbackAnsi: '', @@ -61,8 +62,7 @@ function checkpoint(overrides?: Record): string { mouseTracking: false, applicationCursor: false, alternateScreen: false - }, - ...overrides + } }) } @@ -82,27 +82,12 @@ describe('terminal history restore memory limits', () => { expect(restore?.snapshotAnsi).toBe('safe checkpoint') }) - // Why: the read cap once sat at 16MiB while the writer stayed unbounded, so a big-scrollback - // session cold-restored to an empty terminal — checkpoint nulled, and every fallback collapsed. - it('restores a checkpoint larger than the retired 16MiB read cap', async () => { - const { basePath, sessionPath } = createSession('large-checkpoint') - const scrollbackAnsi = 'x'.repeat(RETIRED_CHECKPOINT_READ_CAP_BYTES + 1) - writeFileSync(join(sessionPath, 'checkpoint.json'), checkpoint({ scrollbackAnsi })) - - const restore = await new HistoryReader(basePath).detectColdRestore('large-checkpoint') - expect(restore?.scrollbackAnsi).toBe(scrollbackAnsi) - }) - - // Why the cap survives at all: it bounds a corrupt/runaway file, well above legitimate output. it('ignores oversized checkpoint and metadata files before parsing', async () => { const checkpointSession = createSession('oversized-checkpoint') - const oversizedCheckpoint = join(checkpointSession.sessionPath, 'checkpoint.json') - createSparseFile(oversizedCheckpoint, TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1) - // Why assert the reader directly too: detectColdRestore returns null for any unparseable - // checkpoint, so it would stay green with the byte cap removed entirely. - expect(() => - readTerminalHistoryJson(oversizedCheckpoint, TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES) - ).toThrow(/File too large/) + createSparseFile( + join(checkpointSession.sessionPath, 'checkpoint.json'), + TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1 + ) expect( await new HistoryReader(checkpointSession.basePath).detectColdRestore('oversized-checkpoint') ).toBeNull() @@ -117,54 +102,21 @@ describe('terminal history restore memory limits', () => { ).toBe(false) }) - // Why: the retired 1M-structural-token pre-scan was reachable by ordinary content — - // ~100k OSC-8 hyperlinks cross it, the assert threw, detectColdRestore swallowed it, - // and the terminal restored blank. Same user-visible loss as the retired byte cap. - it('restores a checkpoint carrying more OSC links than the retired structural-token cap', async () => { - const { basePath, sessionPath } = createSession('many-osc-links') - const oscLinks = Array.from({ length: 150_000 }, (_, index) => ({ - row: index, - startCol: 0, - endCol: 40, - uri: `https://example.com/build/${index}` - })) - writeFileSync(join(sessionPath, 'checkpoint.json'), checkpoint({ oscLinks })) - - const restore = await new HistoryReader(basePath).detectColdRestore('many-osc-links') - expect(restore?.snapshotAnsi).toBe('safe checkpoint') - expect(restore?.oscLinks).toHaveLength(oscLinks.length) - }) - - // Why assert the reader directly too: detectColdRestore hides any reader throw as a - // null checkpoint, so a reinstated pre-scan would stay invisible above. - it('parses link-dense checkpoints without a structural pre-scan budget', () => { - const { sessionPath } = createSession('link-dense-checkpoint') + it('rejects structurally amplified checkpoints before parsing', () => { + const { sessionPath } = createSession('amplified-checkpoint') const checkpointPath = join(sessionPath, 'checkpoint.json') - writeFileSync(checkpointPath, `{"snapshotAnsi":"","values":[${'0,'.repeat(2_000_000)}0]}`) - expect( - readTerminalHistoryJson<{ values: number[] }>( - checkpointPath, - TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES - ).values - ).toHaveLength(2_000_001) - }) - - // Why: the pre-scan also carried a 128-level nesting cap, and dropping it is only safe - // because V8 parses JSON iteratively — depth costs heap, not stack. A future engine that - // recursed would abort the daemon rather than throw into the callers' catch, so pin it. - it('parses deeply nested checkpoints without the retired nesting-depth cap', () => { - const { sessionPath } = createSession('deeply-nested-checkpoint') - const checkpointPath = join(sessionPath, 'checkpoint.json') - const depth = 50_000 writeFileSync( checkpointPath, - `{"snapshotAnsi":"","nested":${'['.repeat(depth)}${']'.repeat(depth)}}` + `{"snapshotAnsi":"","values":[${'0,'.repeat(TERMINAL_HISTORY_JSON_MAX_STRUCTURAL_TOKENS)}0]}` ) - expect( - readTerminalHistoryJson<{ nested: unknown[] }>( - checkpointPath, - TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES - ).nested - ).toHaveLength(1) + const parseSpy = vi.spyOn(JSON, 'parse') + try { + expect(() => + readTerminalHistoryJson(checkpointPath, TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES) + ).toThrow(/JSON structure exceeds/) + expect(parseSpy).not.toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } }) }) diff --git a/src/main/daemon/ndjson.test.ts b/src/main/daemon/ndjson.test.ts index 3d1d2c1512b..a7075f5194a 100644 --- a/src/main/daemon/ndjson.test.ts +++ b/src/main/daemon/ndjson.test.ts @@ -1,7 +1,20 @@ import { describe, expect, it, vi } from 'vitest' -import { encodeNdjson, createNdjsonParser, NDJSON_MAX_LINE_BYTES } from './ndjson' +import { + encodeBoundedNdjson, + encodeNdjson, + createNdjsonParser, + NDJSON_MAX_LINE_BYTES, + NDJSON_MAX_STRUCTURAL_TOKENS +} from './ndjson' describe('encodeNdjson', () => { + it('bounds response serialization while preserving admitted wire bytes', () => { + expect(encodeBoundedNdjson({ ok: true }, 12)).toBe('{"ok":true}\n') + expect(() => encodeBoundedNdjson({ value: 'x'.repeat(100) }, 32)).toThrow( + 'JSON output exceeds 31 bytes' + ) + }) + it('encodes an object as a JSON line ending with newline', () => { const result = encodeNdjson({ type: 'hello', version: 1 }) expect(result).toBe('{"type":"hello","version":1}\n') @@ -32,6 +45,32 @@ describe('createNdjsonParser', () => { expect(onError).not.toHaveBeenCalled() }) + it('optionally reports each parsed line byte length without re-serializing it', () => { + const onMessage = vi.fn() + const parser = createNdjsonParser(onMessage, undefined, { includeLineBytes: true }) + + parser.feed('{"text":"é"}\n') + + expect(onMessage).toHaveBeenCalledWith({ text: 'é' }, Buffer.byteLength('{"text":"é"}')) + }) + + it('rejects structurally amplified lines before parsing', () => { + const onMessage = vi.fn() + const onError = vi.fn() + const parser = createNdjsonParser(onMessage, onError) + const parseSpy = vi.spyOn(JSON, 'parse') + try { + parser.feed(`{"values":[${'0,'.repeat(NDJSON_MAX_STRUCTURAL_TOKENS)}0]}\n`) + expect(onMessage).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('JSON structure exceeds') }) + ) + expect(parseSpy).not.toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } + }) + it('parses multiple messages in a single chunk', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) @@ -56,6 +95,20 @@ describe('createNdjsonParser', () => { expect(onMessage).toHaveBeenCalledWith({ type: 'hello', version: 1 }) }) + it('parses a line delivered in more than 100,000 one-character fragments', () => { + const onMessage = vi.fn() + const parser = createNdjsonParser(onMessage, undefined, { includeLineBytes: true }) + const message = { value: 'x'.repeat(100_000) } + const line = JSON.stringify(message) + + for (const character of line) { + parser.feed(character) + } + parser.feed('\n') + + expect(onMessage).toHaveBeenCalledWith(message, Buffer.byteLength(line)) + }) + it('handles a chunk that ends mid-line followed by more data', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) diff --git a/src/main/daemon/ndjson.ts b/src/main/daemon/ndjson.ts index 17b37cb8d11..8c8ca919732 100644 --- a/src/main/daemon/ndjson.ts +++ b/src/main/daemon/ndjson.ts @@ -1,8 +1,22 @@ +import { assertJsonTextStructureWithinLimits } from '../../shared/memory-safety/json-text-structure-limit' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' +import { stringifyJsonWithinByteLimit } from '../../shared/memory-safety/node-bounded-json-stringify' + export function encodeNdjson(msg: unknown): string { return `${JSON.stringify(msg)}\n` } +export function encodeBoundedNdjson(msg: unknown, maxBytes: number): string { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new RangeError('NDJSON byte limit must be a positive safe integer') + } + return `${stringifyJsonWithinByteLimit(msg, maxBytes - 1).serialized}\n` +} + export const NDJSON_MAX_LINE_BYTES = 16 * 1024 * 1024 +export const DAEMON_HANDSHAKE_MAX_LINE_BYTES = 64 * 1024 +export const NDJSON_MAX_STRUCTURAL_TOKENS = 1_000_000 +export const NDJSON_MAX_NESTING_DEPTH = 128 export type NdjsonParser = { feed(chunk: string): void @@ -11,21 +25,20 @@ export type NdjsonParser = { export type NdjsonParserOptions = { maxLineBytes?: number + includeLineBytes?: boolean } export function createNdjsonParser( - onMessage: (msg: unknown) => void, + onMessage: (msg: unknown, lineBytes?: number) => void, onError?: (err: Error) => void, options: NdjsonParserOptions = {} ): NdjsonParser { - let buffer = '' - let bufferBytes = 0 + const buffer = new GrowingByteBuffer() let discardingOversizedLine = false const maxLineBytes = Math.max(1, options.maxLineBytes ?? NDJSON_MAX_LINE_BYTES) const clearBuffer = (): void => { - buffer = '' - bufferBytes = 0 + buffer.clear() } const reportOversizedLine = (observedBytes: number): void => { @@ -53,8 +66,8 @@ export function createNdjsonParser( return } - const segmentBytes = Buffer.byteLength(segment, 'utf8') - const nextLineBytes = bufferBytes + segmentBytes + const segmentBytes = Buffer.from(segment, 'utf8') + const nextLineBytes = buffer.byteLength + segmentBytes.byteLength // Why: daemon sockets are local but persistent; a peer that never sends // a newline must not grow the parser buffer without bound. if (nextLineBytes > maxLineBytes) { @@ -67,21 +80,29 @@ export function createNdjsonParser( continue } - buffer += segment - bufferBytes = nextLineBytes + buffer.append(segmentBytes) if (!hasNewline) { return } - const line = buffer - clearBuffer() + const lineBytes = buffer.byteLength + const line = buffer.takeString('utf8') if (line.length === 0) { continue } try { - onMessage(JSON.parse(line)) + assertJsonTextStructureWithinLimits(line, { + structuralTokens: NDJSON_MAX_STRUCTURAL_TOKENS, + nestingDepth: NDJSON_MAX_NESTING_DEPTH + }) + const parsed = JSON.parse(line) + if (options.includeLineBytes) { + onMessage(parsed, lineBytes) + } else { + onMessage(parsed) + } } catch (err) { onError?.(err instanceof Error ? err : new Error(String(err))) } diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index fe832e9131f..1930b6e23b5 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1600,43 +1600,27 @@ describe('createPtySubprocess', () => { ) }) - it('falls back to the safe default cwd for daemon agent startup without an explicit cwd', () => { - const proc = mockPtyProcess() - spawnMock.mockReturnValue(proc) - spawnMock.mockClear() + it('rejects daemon automatic agent startup without an explicit cwd', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'linux' }) - const origHome = process.env.HOME - // Pin HOME so we assert the exact resolved candidate, not just non-root-ness — - // catches regressions where resolveSafePtyDefaultCwd picks an unintended home. - process.env.HOME = '/home/testuser' + spawnMock.mockClear() try { - // Why: omitted cwd resolves to a safe default home; guard must not reject before fallback (#9578). expect(() => createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, - command: 'opencode' + command: 'codex' }) - ).not.toThrow() - - expect(spawnMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ cwd: '/home/testuser' }) - ) + ).toThrow(/requires a non-root workspace/) } finally { if (platform) { Object.defineProperty(process, 'platform', platform) } - if (origHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = origHome - } } + + expect(spawnMock).not.toHaveBeenCalled() }) it('rejects daemon automatic agent startup at POSIX root', () => { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 3978dc9f884..db721e4d6a4 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -66,6 +66,7 @@ import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../provider import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' import type { TuiAgent } from '../../shared/types' import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' +import { appendCompactedStringChunk } from '../../shared/string-chunk-compaction' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -592,12 +593,10 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' - // Why: gate on the effective cwd, not raw opts.cwd — an omitted cwd becomes a safe - // default (mirrors LocalPtyProvider). Guarding first treated undefined as root-like (#9578). - const requestedCwd = opts.cwd || getDefaultCwd() if (opts.command && startupAgentRecognition) { - assertSafeAgentStartupCwd(requestedCwd, opts.command) + assertSafeAgentStartupCwd(opts.cwd, opts.command) } + const requestedCwd = opts.cwd || getDefaultCwd() let spawnCwd = requestedCwd let validationCwd = spawnCwd @@ -812,7 +811,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl const bufferPreListenerData = (data: string): void => { // Why: Windows shell-arg startup commands can print before Session wires this subprocess in; preserve that spawn-time race window. - pendingPreListenerData.push(data) + appendCompactedStringChunk(pendingPreListenerData, data) pendingPreListenerDataChars += data.length while (pendingPreListenerDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) { const removed = pendingPreListenerData.shift() diff --git a/src/main/daemon/session-pending-output.test.ts b/src/main/daemon/session-pending-output.test.ts index 446c080a504..3d1d8b7de6d 100644 --- a/src/main/daemon/session-pending-output.test.ts +++ b/src/main/daemon/session-pending-output.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Session } from './session' +import { PENDING_OUTPUT_MAX_RECORDS, Session } from './session' // Coverage for the incremental-checkpoint record stream (issue #5096): every // PTY byte, resize, and clear is recorded so the 5s checkpoint can persist @@ -115,6 +115,25 @@ describe('Session pending output', () => { expect(recovered!.records).toEqual([{ kind: 'output', data: 'post-overflow' }]) }) + it('flags overflow when tiny records reach the metadata cap', () => { + const subprocess = createMockSubprocess() + const live = createSession(subprocess) + + for (let index = 0; index <= PENDING_OUTPUT_MAX_RECORDS / 2; index += 1) { + subprocess.simulateData('x') + live.clearScrollback() + } + + const overflowed = live.takePendingOutput(false) + expect(overflowed!.overflowed).toBe(true) + expect(overflowed!.records).toEqual([]) + + subprocess.simulateData('post-overflow') + expect(live.takePendingOutput(false)!.records).toEqual([ + { kind: 'output', data: 'post-overflow' } + ]) + }) + it('returns the snapshot and drops records in the same take when requested', () => { const subprocess = createMockSubprocess() const live = createSession(subprocess) diff --git a/src/main/daemon/session-pre-ready-input-memory.test.ts b/src/main/daemon/session-pre-ready-input-memory.test.ts new file mode 100644 index 00000000000..4aadf16895a --- /dev/null +++ b/src/main/daemon/session-pre-ready-input-memory.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { PRE_READY_STDIN_MAX_SEGMENTS, Session, type SubprocessHandle } from './session' + +function createSubprocess(): SubprocessHandle { + return { + pid: 12345, + getForegroundProcess: () => null, + write() {}, + resize() {}, + kill() {}, + forceKill() {}, + signal() {}, + onData() {}, + onExit() {}, + dispose() {} + } +} + +describe('Session pre-ready input memory', () => { + it('bounds one-character segments independently of the byte cap', () => { + const session = new Session({ + sessionId: 'pre-ready-segment-test', + cols: 80, + rows: 24, + subprocess: createSubprocess(), + shellReadySupported: true + }) + + try { + for (let index = 0; index < PRE_READY_STDIN_MAX_SEGMENTS; index += 1) { + session.write('x') + } + expect(() => session.write('x')).toThrow('safe memory limit') + } finally { + session.dispose() + } + }) +}) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index bc4b45484f3..3a68c1c584f 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PRE_READY_STDIN_MAX_CODE_UNITS } from './session' import { PRODUCER_PAUSE_FAILSAFE_MS, SESSION_FORCE_KILL_RETRY_MS, Session } from './session' import type { SessionState, ShellReadyState } from './types' import type { TuiAgent } from '../../shared/types' @@ -352,6 +353,19 @@ describe('Session', () => { expect(subprocess.written).toEqual(['first\n', 'second\n']) }) + it('bounds aggregate input retained before shell readiness without changing admitted writes', () => { + createSession({ shellReadySupported: true }) + const chunk = 'x'.repeat(16 * 1024) + const admittedChunks = PRE_READY_STDIN_MAX_CODE_UNITS / chunk.length + + for (let index = 0; index < admittedChunks; index += 1) { + session.write(chunk) + } + expect(() => session.write('overflow')).toThrow('safe memory limit') + vi.advanceTimersByTime(15_000) + expect(subprocess.written).toEqual(Array(admittedChunks).fill(chunk)) + }) + it('uses the short settle path when marker and prompt bytes arrive together', () => { createSession({ shellReadySupported: true }) session.write('codex\n') diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7edaa500663..cbec43de1e4 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -37,6 +37,9 @@ const SESSION_FORCE_KILL_MAX_ATTEMPTS = 2 // Why: bounds in-memory pending output when no client drains it; past the cap we drop records and flag // overflow so the next take falls back to one full snapshot. UTF-16 units; worst-case wire is ~6x, under NDJSON_MAX_LINE_BYTES (16MB). const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024 +export const PENDING_OUTPUT_MAX_RECORDS = 4_096 +export const PRE_READY_STDIN_MAX_CODE_UNITS = 16 * 1024 * 1024 +export const PRE_READY_STDIN_MAX_SEGMENTS = 4_096 // Why: pause is a fire-and-forget notify, so a resume can be lost (main crash, dropped socket); a lost // resume must never wedge a shell, so auto-resume after this window — a still-flooded main re-pauses. export const PRODUCER_PAUSE_FAILSAFE_MS = 5_000 @@ -112,6 +115,7 @@ export class Session { private readonly onSessionExit?: (code: number) => void private attachedClients: AttachedClient[] = [] private preReadyStdinQueue: string[] = [] + private preReadyStdinCodeUnits = 0 private shellReadyScanState: ShellReadyScanState | null = null private shellReadyTimer: ReturnType | null = null private killTimer: ReturnType | null = null @@ -218,7 +222,17 @@ export class Session { // Why: keep queuing during the post-ready flush-gate window ('ready' but not yet flushed); a // direct write would race fresh input ahead of the buffered startup command. if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) { + const nextCodeUnits = this.preReadyStdinCodeUnits + data.length + if ( + nextCodeUnits > PRE_READY_STDIN_MAX_CODE_UNITS || + this.preReadyStdinQueue.length >= PRE_READY_STDIN_MAX_SEGMENTS + ) { + throw new Error( + 'Terminal input queued before shell readiness exceeds the safe memory limit.' + ) + } this.preReadyStdinQueue.push(data) + this.preReadyStdinCodeUnits = nextCodeUnits return } @@ -523,6 +537,7 @@ export class Session { this.attachedClients = [] this.preReadyStdinQueue = [] + this.preReadyStdinCodeUnits = 0 this.postReadyFlushGate.clear() this.emulator.dispose() @@ -567,6 +582,7 @@ export class Session { } this.shellReadyScanState = null this.preReadyStdinQueue = [] + this.preReadyStdinCodeUnits = 0 this.postReadyFlushGate.clear() this.disposeSubprocessHandle() } @@ -589,15 +605,20 @@ export class Session { return } const bytes = record.kind === 'output' ? record.data.length : 8 - if (this.pendingOutputBytes + bytes > PENDING_OUTPUT_MAX_BYTES) { + const last = this.pendingOutputRecords.at(-1) + const canCoalesce = + record.kind === 'output' && last?.kind === 'output' && last.data.length < 64 * 1024 + if ( + this.pendingOutputBytes + bytes > PENDING_OUTPUT_MAX_BYTES || + (!canCoalesce && this.pendingOutputRecords.length >= PENDING_OUTPUT_MAX_RECORDS) + ) { this.pendingOutputRecords = [] this.pendingOutputBytes = 0 this.pendingOutputOverflowed = true return } // Why: coalesce the thousands of tiny TUI chunks per tick to keep take RPC/log frames compact; 64KB cap bounds append cost. - const last = this.pendingOutputRecords.at(-1) - if (record.kind === 'output' && last?.kind === 'output' && last.data.length < 64 * 1024) { + if (canCoalesce) { last.data += record.data } else { this.pendingOutputRecords.push(record) @@ -720,6 +741,7 @@ export class Session { private flushPreReadyQueue(): void { const queued = this.preReadyStdinQueue this.preReadyStdinQueue = [] + this.preReadyStdinCodeUnits = 0 for (const data of queued) { this.subprocess.write(data) } diff --git a/src/main/daemon/terminal-history-file-limits.ts b/src/main/daemon/terminal-history-file-limits.ts index 5fc4523d785..3a8e4a1891e 100644 --- a/src/main/daemon/terminal-history-file-limits.ts +++ b/src/main/daemon/terminal-history-file-limits.ts @@ -1,8 +1,4 @@ export const TERMINAL_HISTORY_META_MAX_BYTES = 64 * 1024 export const TERMINAL_HISTORY_LOG_MAX_BYTES = 5 * 1024 * 1024 -// Why deliberately generous: the checkpoint writer is unbounded, and a read cap under what it -// can emit silently drops ALL scrollback on cold restore. This guards a corrupt/runaway file, -// not retention — a 50k-row max preset of ordinary text serializes to ~14MB, ~15x under. Output -// colored per cell can still exceed this; trimming the snapshot writer-side is the real fix. -export const TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES = 200_000_000 +export const TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES = 16 * 1024 * 1024 export const TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES = 16 * 1024 * 1024 diff --git a/src/main/daemon/terminal-history-file-reader.ts b/src/main/daemon/terminal-history-file-reader.ts index 4a67715fc46..d0203e0a124 100644 --- a/src/main/daemon/terminal-history-file-reader.ts +++ b/src/main/daemon/terminal-history-file-reader.ts @@ -2,6 +2,10 @@ import { readNodeFileSyncWithinLimit, readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { assertJsonTextStructureWithinLimits } from '../../shared/memory-safety/json-text-structure-limit' + +export const TERMINAL_HISTORY_JSON_MAX_STRUCTURAL_TOKENS = 1_000_000 +export const TERMINAL_HISTORY_JSON_MAX_NESTING_DEPTH = 128 export function readTerminalHistoryBuffer(filePath: string, maxBytes: number): Buffer { return readNodeFileSyncWithinLimit(filePath, maxBytes).buffer @@ -11,16 +15,17 @@ export function readTerminalHistoryText(filePath: string, maxBytes: number): str return readTerminalHistoryBuffer(filePath, maxBytes).toString('utf8') } -// Why no JSON structure pre-scan here: checkpoint.json and meta.json are our own -// writer's output, not untrusted input — the byte cap still bounds the read and a -// corrupt file fails JSON.parse into every caller's existing catch. Untrusted JSON -// still goes through assertJsonTextStructureWithinLimits. export function readTerminalHistoryJson(filePath: string, maxBytes: number): T { - return JSON.parse(readTerminalHistoryText(filePath, maxBytes)) as T + const text = readTerminalHistoryText(filePath, maxBytes) + assertJsonTextStructureWithinLimits(text, { + structuralTokens: TERMINAL_HISTORY_JSON_MAX_STRUCTURAL_TOKENS, + nestingDepth: TERMINAL_HISTORY_JSON_MAX_NESTING_DEPTH + }) + return JSON.parse(text) as T } // Why: cold-restore payload reads must not block the main thread, but need the -// same byte bound as the sync readers. +// same byte and JSON-structure bounds as the sync readers. export async function readTerminalHistoryBufferAsync( filePath: string, maxBytes: number @@ -39,5 +44,10 @@ export async function readTerminalHistoryJsonAsync( filePath: string, maxBytes: number ): Promise { - return JSON.parse(await readTerminalHistoryTextAsync(filePath, maxBytes)) as T + const text = await readTerminalHistoryTextAsync(filePath, maxBytes) + assertJsonTextStructureWithinLimits(text, { + structuralTokens: TERMINAL_HISTORY_JSON_MAX_STRUCTURAL_TOKENS, + nestingDepth: TERMINAL_HISTORY_JSON_MAX_NESTING_DEPTH + }) + return JSON.parse(text) as T } diff --git a/src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts b/src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts deleted file mode 100644 index a4b4fbe4ba3..00000000000 --- a/src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { mkdtempSync, rmSync, statSync } from 'node:fs' -import { HistoryManager } from './history-manager' -import { HistoryReader } from './history-reader' -import { HeadlessEmulator } from './headless-emulator' -import { LOG_HEADER_BYTES } from './terminal-history-log' -import { getHistorySessionDirName } from './history-paths' - -// Guards checkpoint-only cold restore: the route taken when the daemon dies right after a -// checkpoint, so output.log is header-only and checkpoint.json is the sole recovery source. -// #10179 shipped a read cap (16MiB) under what the unbounded checkpoint writer can emit; past it -// the read threw, the catch swallowed it to checkpoint=null, and the pane reopened empty (#10479 -// raised the cap; nothing pinned the round trip that the cap silently broke). The -// reader-level bound is covered in history-reader-memory.test.ts — this asserts the round trip -// through the real writer, which is what actually decides whether a user sees their scrollback. - -const MARKERS = 300 -const SESSION_ID = 'repo-1::/Users/dev/large-scrollback' -const COLS = 800 -const ROWS = 40 - -// Why per-cell color: the restore seed must clear 16MiB to cover the pre-#10479 cap, and -// SGR-per-cell is how real agent/build output inflates a snapshot well past its plain-text size. -// It is also what keeps the fixture affordable — the xterm buffer costs rows x cols, so carrying -// the bytes in SGR runs instead of rows reaches 25MiB of seed from 5.5k rows rather than 26k, -// cutting this file's peak RSS from ~1.2GiB to ~800MiB. Only 8 distinct rows exist under -// (row + col) % 8, so build them once rather than per line. -const FILLER_ROWS = Array.from( - { length: 8 }, - (_unused, row) => - `${Array.from({ length: COLS - 1 }, (_cell, col) => `\x1b[3${(row + col) % 8}mx`).join('')}\x1b[0m\r\n` -) - -function writeLargeScrollback(emulator: HeadlessEmulator, fillerLines: number): void { - let written = true - for (let index = 0; index < fillerLines; index += 1) { - written = emulator.writeSync(FILLER_ROWS[index % FILLER_ROWS.length]) && written - } - for (let index = 0; index < MARKERS; index += 1) { - written = emulator.writeSync(`MARKER-${index}\r\n`) && written - } - // Why assert: writeSync returns false if xterm's private _core.writeSync ever goes away, which - // would leave an empty snapshot. The size assertions below catch that, but the endedAt gate is - // size-independent and would still pass — pinning the gate over no scrollback at all. - expect(written).toBe(true) -} - -describe('checkpoint-only cold restore of a large checkpoint', () => { - let dir: string - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'large-checkpoint-restore-')) - }) - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }) - }) - - it('recovers the full scrollback from a checkpoint larger than the pre-fix 16MiB read cap', async () => { - const manager = new HistoryManager(dir) - const reader = new HistoryReader(dir) - const emulator = new HeadlessEmulator({ cols: COLS, rows: ROWS, scrollback: 100_000 }) - writeLargeScrollback(emulator, 5_500) - - await manager.openSession(SESSION_ID, { - cwd: '/Users/dev/large-scrollback', - cols: COLS, - rows: ROWS - }) - // Why dispose first: a throwing checkpoint() must not leak the buffer for the worker's life. - const snapshot = emulator.getSnapshot() - emulator.dispose() - await manager.checkpoint(SESSION_ID, snapshot) - - const sessionDir = join(dir, getHistorySessionDirName(SESSION_ID)) - // checkpoint() resets the log to its header, so this is genuinely checkpoint-only: any - // recovered content had to come through the checkpoint read, not incremental log replay. - expect(statSync(join(sessionDir, 'output.log')).size).toBe(LOG_HEADER_BYTES) - expect(statSync(join(sessionDir, 'checkpoint.json')).size).toBeGreaterThan(16 * 1024 * 1024) - - const info = await reader.detectColdRestore(SESSION_ID) - expect(info).not.toBeNull() - // The adapter seeds a non-alt-screen restore with rehydrateSequences + snapshotAnsi. - const seed = info!.rehydrateSequences + info!.snapshotAnsi - expect(info!.modes.alternateScreen).toBe(false) - for (const index of [0, MARKERS - 1]) { - expect(seed).toContain(`MARKER-${index}`) - } - expect(seed.length).toBeGreaterThan(16 * 1024 * 1024) - }, 60_000) - - // Why pinned: this gate, not any size cap, is what makes a checkpoint-only restore come back - // blank. Two separate investigations mistook a cleanly-ended session for a size regression. - it('restores after an unclean exit but not after a clean one, at the same checkpoint size', async () => { - const restoreAfter = async (endCleanly: boolean): Promise => { - const manager = new HistoryManager(dir) - const emulator = new HeadlessEmulator({ cols: COLS, rows: ROWS, scrollback: 100_000 }) - writeLargeScrollback(emulator, 50) - await manager.openSession(SESSION_ID, { - cwd: '/Users/dev/large-scrollback', - cols: COLS, - rows: ROWS - }) - const snapshot = emulator.getSnapshot() - emulator.dispose() - await manager.checkpoint(SESSION_ID, snapshot) - if (endCleanly) { - await manager.closeSession(SESSION_ID, 0) - } - const info = await new HistoryReader(dir).detectColdRestore(SESSION_ID) - rmSync(join(dir, getHistorySessionDirName(SESSION_ID)), { recursive: true, force: true }) - return info !== null - } - - expect(await restoreAfter(false)).toBe(true) - expect(await restoreAfter(true)).toBe(false) - }, 60_000) -}) diff --git a/src/main/daemon/terminal-history-log-state.ts b/src/main/daemon/terminal-history-log-state.ts new file mode 100644 index 00000000000..6ad44e0977a --- /dev/null +++ b/src/main/daemon/terminal-history-log-state.ts @@ -0,0 +1,55 @@ +import { closeSync, fstatSync, openSync, readSync } from 'node:fs' +import { decodeLogHeader, LOG_HEADER_BYTES } from './terminal-history-log' +import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits' +import { readTerminalHistoryJson } from './terminal-history-file-reader' + +export type TerminalHistoryLogState = { + checkpointPath: string + logPath: string + /** Null until a warm writer resolves the existing log on first append. */ + logGeneration: number | null + /** Null until resolved alongside the generation. */ + logBytes: number | null +} + +export function resolveTerminalHistoryLogState(writer: TerminalHistoryLogState): void { + if (writer.logBytes !== null && writer.logGeneration !== null) { + return + } + let headerGeneration: number | null = null + let size = 0 + try { + const fd = openSync(writer.logPath, 'r') + try { + size = fstatSync(fd).size + const header = Buffer.alloc(LOG_HEADER_BYTES) + if (readSync(fd, header, 0, LOG_HEADER_BYTES, 0) === LOG_HEADER_BYTES) { + headerGeneration = decodeLogHeader(header) + } + } finally { + closeSync(fd) + } + } catch { + // Missing log file starts a fresh generation below. + } + if (headerGeneration !== null) { + writer.logGeneration = headerGeneration + writer.logBytes = size + return + } + // Why: a zero byte count makes the next append replace a corrupt or headerless log. + writer.logBytes = 0 + writer.logGeneration = readCheckpointGeneration(writer.checkpointPath) ?? 0 +} + +function readCheckpointGeneration(checkpointPath: string): number | null { + try { + const checkpoint = readTerminalHistoryJson<{ generation?: unknown }>( + checkpointPath, + TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + ) + return typeof checkpoint.generation === 'number' ? checkpoint.generation : null + } catch { + return null + } +} diff --git a/src/main/daemon/terminal-history-log.test.ts b/src/main/daemon/terminal-history-log.test.ts index 426056d4725..0fd6fea07d3 100644 --- a/src/main/daemon/terminal-history-log.test.ts +++ b/src/main/daemon/terminal-history-log.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { decodeLogHeader, decodeTerminalHistoryLog, encodeLogBatch, + encodeLogBatchWithinLimit, encodeLogHeader, LOG_HEADER_BYTES } from './terminal-history-log' @@ -44,6 +45,16 @@ describe('terminal history log codec', () => { expect(log!.batches).toEqual([{ seq: 3, records }]) }) + it('rejects an oversized batch before allocating its encoded buffer', () => { + const allocation = vi.spyOn(Buffer, 'allocUnsafe') + + expect( + encodeLogBatchWithinLimit(1, [{ kind: 'output', data: 'escaped 🐋 output' }], 12) + ).toBeNull() + expect(allocation).not.toHaveBeenCalled() + allocation.mockRestore() + }) + it('decodes multiple contiguous batches', () => { const log = decodeTerminalHistoryLog( buildLog(1, [ diff --git a/src/main/daemon/terminal-history-log.ts b/src/main/daemon/terminal-history-log.ts index 4f7866c37d6..ba260d32dca 100644 --- a/src/main/daemon/terminal-history-log.ts +++ b/src/main/daemon/terminal-history-log.ts @@ -23,6 +23,8 @@ const FRAME_BATCH = 0x01 const FRAME_OUTPUT = 0x02 const FRAME_RESIZE = 0x03 const FRAME_CLEAR = 0x04 +const FRAME_HEADER_BYTES = 5 +const BATCH_FRAME_BYTES = FRAME_HEADER_BYTES + 4 export type TerminalHistoryLogBatch = { seq: number @@ -61,20 +63,75 @@ export function decodeLogHeader(buffer: Buffer): number | null { } export function encodeLogBatch(seq: number, records: PendingOutputRecord[]): Buffer { - const frames: Buffer[] = [encodeFrame(FRAME_BATCH, encodeSeqPayload(seq))] + const byteLength = measureLogBatchBytes(records) + return encodeLogBatchWithByteLength(seq, records, byteLength) +} + +export function encodeLogBatchWithinLimit( + seq: number, + records: PendingOutputRecord[], + maxBytes: number +): Buffer | null { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + return null + } + const byteLength = measureLogBatchBytes(records, maxBytes) + return byteLength === null ? null : encodeLogBatchWithByteLength(seq, records, byteLength) +} + +function measureLogBatchBytes(records: PendingOutputRecord[]): number +function measureLogBatchBytes(records: PendingOutputRecord[], maxBytes: number): number | null +function measureLogBatchBytes(records: PendingOutputRecord[], maxBytes?: number): number | null { + let byteLength = BATCH_FRAME_BYTES + if (maxBytes !== undefined && byteLength > maxBytes) { + return null + } for (const record of records) { if (record.kind === 'output') { - frames.push(encodeFrame(FRAME_OUTPUT, Buffer.from(record.data, 'utf8'))) + const payloadBytes = Buffer.byteLength(record.data, 'utf8') + if (maxBytes !== undefined && payloadBytes > maxBytes - byteLength - FRAME_HEADER_BYTES) { + return null + } + byteLength += FRAME_HEADER_BYTES + payloadBytes } else if (record.kind === 'resize') { - const payload = Buffer.alloc(4) - payload.writeUInt16LE(clampU16(record.cols), 0) - payload.writeUInt16LE(clampU16(record.rows), 2) - frames.push(encodeFrame(FRAME_RESIZE, payload)) + byteLength += FRAME_HEADER_BYTES + 4 } else { - frames.push(encodeFrame(FRAME_CLEAR, Buffer.alloc(0))) + byteLength += FRAME_HEADER_BYTES + } + if (maxBytes !== undefined && byteLength > maxBytes) { + return null } } - return Buffer.concat(frames) + return byteLength +} + +function encodeLogBatchWithByteLength( + seq: number, + records: PendingOutputRecord[], + byteLength: number +): Buffer { + const batch = Buffer.allocUnsafe(byteLength) + let offset = 0 + offset = writeFrameHeader(batch, offset, FRAME_BATCH, 4) + batch.writeUInt32LE(seq >>> 0, offset) + offset += 4 + + for (const record of records) { + if (record.kind === 'output') { + const payloadBytes = Buffer.byteLength(record.data, 'utf8') + offset = writeFrameHeader(batch, offset, FRAME_OUTPUT, payloadBytes) + batch.write(record.data, offset, payloadBytes, 'utf8') + offset += payloadBytes + } else if (record.kind === 'resize') { + offset = writeFrameHeader(batch, offset, FRAME_RESIZE, 4) + batch.writeUInt16LE(clampU16(record.cols), offset) + batch.writeUInt16LE(clampU16(record.rows), offset + 2) + offset += 4 + } else { + offset = writeFrameHeader(batch, offset, FRAME_CLEAR, 0) + } + } + return batch } /** Returns null for missing magic / unknown format version — callers fall @@ -147,17 +204,15 @@ export function decodeTerminalHistoryLog(buffer: Buffer): TerminalHistoryLogCont return { generation, batches, truncatedTail } } -function encodeFrame(kind: number, payload: Buffer): Buffer { - const header = Buffer.alloc(5) - header.writeUInt8(kind, 0) - header.writeUInt32LE(payload.length, 1) - return Buffer.concat([header, payload]) -} - -function encodeSeqPayload(seq: number): Buffer { - const payload = Buffer.alloc(4) - payload.writeUInt32LE(seq >>> 0, 0) - return payload +function writeFrameHeader( + buffer: Buffer, + offset: number, + kind: number, + payloadBytes: number +): number { + buffer.writeUInt8(kind, offset) + buffer.writeUInt32LE(payloadBytes, offset + 1) + return offset + FRAME_HEADER_BYTES } function clampU16(value: number): number { diff --git a/src/main/daemon/terminal-history-session-metadata.ts b/src/main/daemon/terminal-history-session-metadata.ts new file mode 100644 index 00000000000..8b876e425c8 --- /dev/null +++ b/src/main/daemon/terminal-history-session-metadata.ts @@ -0,0 +1,36 @@ +import { stringifyJsonWithinByteLimit } from '../../shared/memory-safety/node-bounded-json-stringify' +import { TERMINAL_HISTORY_META_MAX_BYTES } from './terminal-history-file-limits' +import { readTerminalHistoryJson } from './terminal-history-file-reader' + +export type SessionMeta = { + cwd: string + cols: number + rows: number + startedAt: string + endedAt: string | null + exitCode: number | null +} + +export type OpenSessionOptions = { + cwd: string + cols: number + rows: number +} + +export type HistoryManagerOptions = { + onWriteError?: (sessionId: string, error: Error) => void +} + +export function stringifyTerminalHistorySessionMeta(meta: SessionMeta): string { + // Preflight compact JSON before materializing the larger pretty representation. + stringifyJsonWithinByteLimit(meta, TERMINAL_HISTORY_META_MAX_BYTES) + const serialized = JSON.stringify(meta, null, 2) + if (Buffer.byteLength(serialized, 'utf8') > TERMINAL_HISTORY_META_MAX_BYTES) { + throw new Error('Terminal history metadata exceeds its byte limit') + } + return serialized +} + +export function readTerminalHistorySessionMeta(filePath: string): SessionMeta { + return readTerminalHistoryJson(filePath, TERMINAL_HISTORY_META_MAX_BYTES) +} diff --git a/src/main/daemon/terminal-host-session-reaping-leak.test.ts b/src/main/daemon/terminal-host-session-reaping-leak.test.ts index 4a0a0d36446..221a5581fe7 100644 --- a/src/main/daemon/terminal-host-session-reaping-leak.test.ts +++ b/src/main/daemon/terminal-host-session-reaping-leak.test.ts @@ -15,11 +15,6 @@ import { TerminalHost } from './terminal-host' import type { SubprocessHandle } from './session' import { HeadlessEmulator } from './headless-emulator' -const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) -vi.mock('../pty-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - function createMockSubprocess(): SubprocessHandle & { _onExitCb: ((code: number) => void) | null } { @@ -55,14 +50,8 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { let host: TerminalHost let lastSubprocess: ReturnType let emulatorDispose: ReturnType - let platformDescriptor: PropertyDescriptor | undefined beforeEach(() => { - // Pin POSIX so immediate force-kill teardown is deterministic across host OSes; the - // Windows taskkill tree-kill path is covered in terminal-session-teardown.test.ts. - platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - killWithDescendantSweepMock.mockReset() emulatorDispose = vi.spyOn(HeadlessEmulator.prototype, 'dispose') const spawnFn = vi.fn(() => { lastSubprocess = createMockSubprocess() @@ -74,9 +63,6 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { afterEach(async () => { await host.dispose() emulatorDispose.mockRestore() - if (platformDescriptor) { - Object.defineProperty(process, 'platform', platformDescriptor) - } }) function streamClient() { @@ -131,11 +117,6 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { const killed = host.kill('session-1', { immediate: true }) - // Immediate teardown skips the graceful kill and force-kills the child directly. On POSIX - // that reaches the child pgroup, so no Windows taskkill /T /F descendant sweep is needed. - expect(lastSubprocess.kill).not.toHaveBeenCalled() - expect(lastSubprocess.forceKill).toHaveBeenCalled() - expect(killWithDescendantSweepMock).not.toHaveBeenCalled() expect(emulatorDispose).not.toHaveBeenCalled() expect(host.listSessions()).toHaveLength(1) lastSubprocess._onExitCb?.(137) @@ -144,7 +125,6 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { // Emulator freed and session dropped from the map (no lingering dead entry). expect(emulatorDispose).toHaveBeenCalledTimes(1) expect(host.listSessions()).toHaveLength(0) - expect(host.isKilled('session-1')).toBe(true) }) it('retains a graceful-timeout session until the forced child physically exits', async () => { diff --git a/src/main/daemon/terminal-session-teardown.test.ts b/src/main/daemon/terminal-session-teardown.test.ts deleted file mode 100644 index 30406de887a..00000000000 --- a/src/main/daemon/terminal-session-teardown.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TerminalSessionTeardown } from './terminal-session-teardown' -import type { Session } from './session' - -const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) -vi.mock('../pty-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -function createPlainShellSession(overrides: Partial = {}): Session { - return { - launchAgent: undefined, - pid: 4242, - isAlive: true, - forceKillAndWaitForExit: vi.fn(async () => {}), - beginTermination: vi.fn(() => true), - kill: vi.fn(), - ...overrides - } as unknown as Session -} - -describe('TerminalSessionTeardown plain-shell teardown', () => { - let platformDescriptor: PropertyDescriptor | undefined - - beforeEach(() => { - platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') - killWithDescendantSweepMock.mockReset() - killWithDescendantSweepMock.mockResolvedValue(undefined) - }) - - afterEach(() => { - if (platformDescriptor) { - Object.defineProperty(process, 'platform', platformDescriptor) - } - }) - - function setPlatform(value: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { configurable: true, value }) - } - - it('win32 immediate kill taskkills the descendant tree before force-kill', async () => { - // Why: a live pnpm/node child otherwise survives the ConPTY close, keeps the console - // non-empty, and holds the worktree cwd — failing destructive removal (#10004/#10100). - setPlatform('win32') - const session = createPlainShellSession() - const teardown = new TerminalSessionTeardown(new Map([['s1', session]])) - - await teardown.killSession('s1', session, true) - - expect(killWithDescendantSweepMock).toHaveBeenCalledWith( - 4242, - expect.any(Function), - expect.objectContaining({ ownsRoot: expect.any(Function) }) - ) - expect(session.forceKillAndWaitForExit).toHaveBeenCalled() - // The sweep owns the taskkill; the killRoot callback is a no-op so force-kill drives exit. - const killRoot = killWithDescendantSweepMock.mock.calls[0][1] as () => void - expect(() => killRoot()).not.toThrow() - }) - - it('win32 immediate kill claims termination before awaiting the sweep', async () => { - // Why: createOrAttach rejects a doomed plain shell only via isTerminating, so the claim - // must land before the taskkill await or an attach can bind a pane to a dying session. - setPlatform('win32') - const session = createPlainShellSession() - const beginTermination = session.beginTermination as unknown as ReturnType - let claimedBeforeSweep = false - killWithDescendantSweepMock.mockImplementation(async () => { - claimedBeforeSweep = beginTermination.mock.calls.length === 1 - }) - const teardown = new TerminalSessionTeardown(new Map([['s1', session]])) - - await teardown.killSession('s1', session, true) - - expect(claimedBeforeSweep).toBe(true) - }) - - it('win32 sweep ownsRoot guard requires the live session to still own the id', async () => { - setPlatform('win32') - const session = createPlainShellSession() - const sessions = new Map([['s1', session]]) - const teardown = new TerminalSessionTeardown(sessions) - - await teardown.killSession('s1', session, true) - const ownsRoot = (killWithDescendantSweepMock.mock.calls[0][2] as { ownsRoot: () => boolean }) - .ownsRoot - expect(ownsRoot()).toBe(true) - - // A natural exit or reap must stop us from taskkilling a recycled PID. - ;(session as unknown as { isAlive: boolean }).isAlive = false - expect(ownsRoot()).toBe(false) - sessions.delete('s1') - ;(session as unknown as { isAlive: boolean }).isAlive = true - expect(ownsRoot()).toBe(false) - }) - - it('non-win32 immediate kill skips the tree kill (pgroup force-kill suffices)', async () => { - setPlatform('linux') - const session = createPlainShellSession() - const teardown = new TerminalSessionTeardown(new Map([['s1', session]])) - - await teardown.killSession('s1', session, true) - - expect(killWithDescendantSweepMock).not.toHaveBeenCalled() - expect(session.forceKillAndWaitForExit).toHaveBeenCalled() - }) - - it('non-immediate (graceful) kill uses the plain kill path without a sweep', async () => { - setPlatform('win32') - const session = createPlainShellSession() - const teardown = new TerminalSessionTeardown(new Map([['s1', session]])) - - await teardown.killSession('s1', session, false) - - expect(killWithDescendantSweepMock).not.toHaveBeenCalled() - expect(session.forceKillAndWaitForExit).not.toHaveBeenCalled() - expect(session.kill).toHaveBeenCalled() - }) -}) diff --git a/src/main/daemon/terminal-session-teardown.ts b/src/main/daemon/terminal-session-teardown.ts index d9f9d996aa7..1ba58393721 100644 --- a/src/main/daemon/terminal-session-teardown.ts +++ b/src/main/daemon/terminal-session-teardown.ts @@ -49,9 +49,10 @@ export class TerminalSessionTeardown { * reap orphaned children (node-pty `useConptyDll` skips the console-process reap), so a * live `pnpm i`/`node` survives shell exit, keeps the ConPTY console non-empty, and holds * the worktree cwd — failing destructive worktree removal with "Failed to physically stop - * every PTY". taskkill /T /F the tree first so physical exit becomes verifiable. Mirrors - * the agent path (#10004/#10100). POSIX shells already reach their child pgroup on - * forceKill, so they stay on the plain force-kill path. + * every PTY". Tree-kill only when the OS identity probe returns `own`; `unknown`/`foreign`/ + * `absent` skip taskkill and rely on root close alone. Mirrors the agent path + * (#10004/#10100). POSIX shells already reach their child pgroup on forceKill, so they + * stay on the plain force-kill path. */ private async forceKillPlainShellSession(sessionId: string, session: Session): Promise { if (process.platform === 'win32') { diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 2ee1ebd9535..aaa65028c60 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -27,6 +27,7 @@ export { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, CLEAN_DISCONNECT_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, diff --git a/src/main/devin/hook-config-json.ts b/src/main/devin/hook-config-json.ts index 6a316ebc6aa..f0037503218 100644 --- a/src/main/devin/hook-config-json.ts +++ b/src/main/devin/hook-config-json.ts @@ -1,5 +1,7 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { parse as parseJsonc, type ParseError } from 'jsonc-parser' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { AGENT_HOOK_CONFIG_MAX_BYTES } from '../agent-hooks/agent-hook-file-limits' import { isPlainObject, type HooksConfig } from '../agent-hooks/installer-utils' /** Devin documents config.json as JSONC; stock JSON.parse rejects comments. */ @@ -9,7 +11,10 @@ export function readDevinHooksConfig(configPath: string): HooksConfig | null { } try { - const text = readFileSync(configPath, 'utf-8') + const text = readNodeFileSyncWithinLimit( + configPath, + AGENT_HOOK_CONFIG_MAX_BYTES + ).buffer.toString('utf8') return parseDevinHooksConfigText(text, 'Devin config.json') } catch { return null diff --git a/src/main/emulator/android/scrcpy-stream-session.test.ts b/src/main/emulator/android/scrcpy-stream-session.test.ts new file mode 100644 index 00000000000..59fefd4e762 --- /dev/null +++ b/src/main/emulator/android/scrcpy-stream-session.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { + appendScrcpyServerLogPreview, + SCRCPY_SERVER_LOG_PREVIEW_CHARS +} from './scrcpy-stream-session' + +describe('appendScrcpyServerLogPreview', () => { + it('preserves the diagnostic prefix while bounding a chatty long-lived server', () => { + const prefix = 'startup\n' + const first = appendScrcpyServerLogPreview(prefix, Buffer.from('x'.repeat(100_000))) + const complete = appendScrcpyServerLogPreview(first, Buffer.from('ignored forever')) + + expect(complete).toBe(first) + expect(complete).toHaveLength(SCRCPY_SERVER_LOG_PREVIEW_CHARS) + expect(complete.startsWith(prefix)).toBe(true) + }) +}) diff --git a/src/main/emulator/android/scrcpy-stream-session.ts b/src/main/emulator/android/scrcpy-stream-session.ts index b7cec131e2c..552c1bfc3b5 100644 --- a/src/main/emulator/android/scrcpy-stream-session.ts +++ b/src/main/emulator/android/scrcpy-stream-session.ts @@ -29,6 +29,7 @@ import { emulatorProbe, emulatorProbeError } from '../emulator-probe' const DEVICE_NAME_BYTES = 64 const DUMMY_BYTE = 1 const DYNAMIC_FORWARD_PORT = 0 +export const SCRCPY_SERVER_LOG_PREVIEW_CHARS = 1_000 export type ScrcpyStreamCallbacks = { onMeta: (meta: ScrcpyVideoMeta) => void @@ -46,6 +47,14 @@ export type ScrcpyStreamOptions = { maxSize?: number } +export function appendScrcpyServerLogPreview(current: string, chunk: Buffer): string { + const remaining = SCRCPY_SERVER_LOG_PREVIEW_CHARS - current.length + if (remaining <= 0) { + return current + } + return current + chunk.toString().slice(0, remaining) +} + function newScid(): string { // scrcpy parses scid as a SIGNED 32-bit hex int, so mask to 31 bits and pad to // 8 hex digits to match the server's own %08x format. @@ -127,13 +136,13 @@ export class ScrcpyStreamSession { }) let serverLog = '' const capture = (chunk: Buffer): void => { - serverLog += chunk.toString() + serverLog = appendScrcpyServerLogPreview(serverLog, chunk) } this.server.stdout?.on('data', capture) this.server.stderr?.on('data', capture) this.server.on('error', (error) => this.fail(error.message)) this.server.on('exit', (code) => { - emulatorProbe('scrcpy.server.exit', { code, log: serverLog.slice(0, 1000).trim() }) + emulatorProbe('scrcpy.server.exit', { code, log: serverLog.trim() }) if (!this.metaSeen) { this.fail('scrcpy server exited before the video stream started') return diff --git a/src/main/emulator/android/uiautomator-tree.test.ts b/src/main/emulator/android/uiautomator-tree.test.ts index 041c1a4abbd..9d9ec7665f1 100644 --- a/src/main/emulator/android/uiautomator-tree.test.ts +++ b/src/main/emulator/android/uiautomator-tree.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import { EmulatorError } from '../emulator-errors' -import { parseAndroidBounds, parseUiAutomatorXml } from './uiautomator-tree' +import { + ANDROID_UIAUTOMATOR_XML_MAX_ATTRIBUTES_PER_ELEMENT, + ANDROID_UIAUTOMATOR_XML_MAX_DEPTH, + ANDROID_UIAUTOMATOR_XML_MAX_ELEMENTS, + parseAndroidBounds, + parseUiAutomatorXml +} from './uiautomator-tree' // Realistic `uiautomator dump` output: an XML prolog, a root, and // nested self-describing elements (mix of container + self-closing leaf). @@ -97,6 +103,23 @@ describe('parseUiAutomatorXml', () => { expect(() => parseUiAutomatorXml('')).toThrowError(EmulatorError) expect(() => parseUiAutomatorXml('')).toThrowError(EmulatorError) }) + + it('rejects element-count, nesting, and per-element attribute amplification', () => { + const tooManyElements = `${''.repeat( + ANDROID_UIAUTOMATOR_XML_MAX_ELEMENTS + )}` + const tooDeep = `${''.repeat(ANDROID_UIAUTOMATOR_XML_MAX_DEPTH + 1)}${''.repeat( + ANDROID_UIAUTOMATOR_XML_MAX_DEPTH + 1 + )}` + const tooManyAttributes = ` `a${index}="x"` + ).join(' ')} />` + + expect(() => parseUiAutomatorXml(tooManyElements)).toThrow('exceeds 50000 elements') + expect(() => parseUiAutomatorXml(tooDeep)).toThrow('exceeds 256 levels') + expect(() => parseUiAutomatorXml(tooManyAttributes)).toThrow('exceeds 64 attributes') + }) }) describe('parseAndroidBounds', () => { diff --git a/src/main/emulator/android/uiautomator-tree.ts b/src/main/emulator/android/uiautomator-tree.ts index 3a3bb7d6e6a..f63299db528 100644 --- a/src/main/emulator/android/uiautomator-tree.ts +++ b/src/main/emulator/android/uiautomator-tree.ts @@ -18,6 +18,11 @@ export type AndroidAxNode = { // Raw element produced by the parser before mapping to the typed Android node. type RawElement = { tag: string; attributes: Record; children: RawElement[] } +export const ANDROID_UIAUTOMATOR_XML_MAX_BYTES = 16 * 1024 * 1024 +export const ANDROID_UIAUTOMATOR_XML_MAX_ELEMENTS = 50_000 +export const ANDROID_UIAUTOMATOR_XML_MAX_DEPTH = 256 +export const ANDROID_UIAUTOMATOR_XML_MAX_ATTRIBUTES_PER_ELEMENT = 64 + // Parses an Android bounds string "[left,top][right,bottom]" -> AndroidAxBounds, // or null when the format doesn't match. Coordinates may be negative (off-screen). export function parseAndroidBounds(value: string): AndroidAxBounds | null { @@ -40,6 +45,9 @@ export function parseUiAutomatorXml(xml: string): AndroidAxNode { if (xml.trim() === '') { throw new EmulatorError('emulator_error', 'Cannot parse empty uiautomator XML') } + if (Buffer.byteLength(xml) > ANDROID_UIAUTOMATOR_XML_MAX_BYTES) { + throw new EmulatorError('emulator_error', 'uiautomator XML exceeds the 16 MiB limit') + } let root: RawElement try { root = parseDocument(xml) @@ -107,6 +115,7 @@ function setBool( function parseDocument(xml: string): RawElement { let i = 0 const n = xml.length + let elementCount = 0 const fail = (message: string): never => { throw new EmulatorError('emulator_error', `${message} at offset ${i}`) @@ -156,7 +165,14 @@ function parseDocument(xml: string): RawElement { } } - const parseElement = (): RawElement => { + const parseElement = (depth: number): RawElement => { + if (depth > ANDROID_UIAUTOMATOR_XML_MAX_DEPTH) { + fail(`uiautomator XML exceeds ${ANDROID_UIAUTOMATOR_XML_MAX_DEPTH} levels`) + } + elementCount += 1 + if (elementCount > ANDROID_UIAUTOMATOR_XML_MAX_ELEMENTS) { + fail(`uiautomator XML exceeds ${ANDROID_UIAUTOMATOR_XML_MAX_ELEMENTS} elements`) + } if (xml[i] !== '<') { fail('Expected element start') } @@ -166,6 +182,7 @@ function parseDocument(xml: string): RawElement { fail('Expected tag name') } const attributes: Record = {} + let attributeCount = 0 for (;;) { skipWs() if (i >= n) { @@ -186,6 +203,12 @@ function parseDocument(xml: string): RawElement { if (name === '') { fail('Expected attribute name') } + attributeCount += 1 + if (attributeCount > ANDROID_UIAUTOMATOR_XML_MAX_ATTRIBUTES_PER_ELEMENT) { + fail( + `uiautomator XML element exceeds ${ANDROID_UIAUTOMATOR_XML_MAX_ATTRIBUTES_PER_ELEMENT} attributes` + ) + } skipWs() if (xml[i] !== '=') { fail("Expected '=' after attribute name") @@ -207,10 +230,14 @@ function parseDocument(xml: string): RawElement { attributes[name] = decodeEntities(xml.slice(start, i)) i++ } - return parseChildren(tag, attributes) + return parseChildren(tag, attributes, depth) } - const parseChildren = (tag: string, attributes: Record): RawElement => { + const parseChildren = ( + tag: string, + attributes: Record, + depth: number + ): RawElement => { const children: RawElement[] = [] for (;;) { if (i >= n) { @@ -243,7 +270,7 @@ function parseDocument(xml: string): RawElement { } else if (startsWith('', 'processing instruction') } else { - children.push(parseElement()) + children.push(parseElement(depth + 1)) } } } @@ -252,7 +279,7 @@ function parseDocument(xml: string): RawElement { if (i >= n || xml[i] !== '<') { fail('No root element found') } - return parseElement() + return parseElement(1) } // Decodes the predefined XML entities plus numeric character references. diff --git a/src/main/emulator/mjpeg-frame-stream.ts b/src/main/emulator/mjpeg-frame-stream.ts index 315b13f303d..1df5817839c 100644 --- a/src/main/emulator/mjpeg-frame-stream.ts +++ b/src/main/emulator/mjpeg-frame-stream.ts @@ -56,6 +56,9 @@ export class MjpegFrameStream { } start(): void { + if (this.stopped || this.request || this.reconnectTimer) { + return + } this.openRequest() } @@ -70,10 +73,12 @@ export class MjpegFrameStream { this.pending = Buffer.alloc(0) } - private scheduleReconnect(): void { - if (this.stopped || this.reconnectTimer) { + private scheduleReconnect(request: ClientRequest): void { + if (this.stopped || this.request !== request || this.reconnectTimer) { return } + this.request = null + request.destroy() this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null this.openRequest() @@ -81,34 +86,45 @@ export class MjpegFrameStream { } private openRequest(): void { - if (this.stopped) { + if (this.stopped || this.request) { return } const req = requestForUrl(this.streamUrl, (res) => { + if (this.stopped || this.request !== req) { + res.destroy() + return + } if (res.statusCode && res.statusCode >= 400) { + this.scheduleReconnect(req) + res.destroy() this.callbacks.onError(`Simulator stream returned HTTP ${res.statusCode}.`) - res.resume() - this.scheduleReconnect() return } - res.on('data', (chunk: Buffer) => this.handleChunk(chunk)) - res.on('end', () => this.scheduleReconnect()) + res.on('data', (chunk: Buffer) => { + if (!this.stopped && this.request === req) { + this.handleChunk(chunk) + } + }) + res.on('end', () => this.scheduleReconnect(req)) res.on('error', (error) => { + if (this.stopped || this.request !== req) { + return + } + this.scheduleReconnect(req) this.callbacks.onError(error.message) - this.scheduleReconnect() }) }) this.request = req req.on('timeout', () => req.destroy(new Error('Simulator stream timed out.'))) req.on('error', (error) => { - if (this.stopped) { + if (this.stopped || this.request !== req) { return } + this.scheduleReconnect(req) this.callbacks.onError(error.message) - this.scheduleReconnect() }) req.end() } diff --git a/src/main/emulator/scrcpy-video-registry-memory.test.ts b/src/main/emulator/scrcpy-video-registry-memory.test.ts new file mode 100644 index 00000000000..805c7f9bb83 --- /dev/null +++ b/src/main/emulator/scrcpy-video-registry-memory.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + SCRCPY_VIDEO_MAX_GOP_FRAMES, + SCRCPY_VIDEO_MAX_REGISTRY_ENTRIES, + SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE, + SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL, + SCRCPY_VIDEO_MAX_SUBSCRIBERS, + scrcpyVideoRegistry, + type ScrcpyVideoFrameMessage +} from './scrcpy-video-registry' + +const MEBIBYTE = 1024 * 1024 +const registeredDevices = new Set() + +function registerDevice(deviceId: string, close = vi.fn()): void { + scrcpyVideoRegistry.register(deviceId, close) + registeredDevices.add(deviceId) +} + +function frame( + pts: string, + byteLength: number, + options: { config?: boolean; keyFrame?: boolean } = {} +): ScrcpyVideoFrameMessage { + return { + config: options.config === true, + keyFrame: options.keyFrame === true, + pts, + bytes: { byteLength } as ArrayBuffer + } +} + +function replayFrames(deviceId: string): ScrcpyVideoFrameMessage[] { + const frames: ScrcpyVideoFrameMessage[] = [] + const unsubscribe = scrcpyVideoRegistry.subscribe(deviceId, (event) => { + if (event.type === 'frame') { + frames.push(event.frame) + } + }) + unsubscribe() + return frames +} + +function replayBytes(deviceId: string): number { + return replayFrames(deviceId).reduce((total, replayed) => total + replayed.bytes.byteLength, 0) +} + +afterEach(() => { + for (const deviceId of registeredDevices) { + scrcpyVideoRegistry.stop(deviceId) + } + registeredDevices.clear() +}) + +describe('scrcpy video replay retention', () => { + it('sheds an overloaded GOP until a fresh keyframe arrives', () => { + registerDevice('frame-cap') + scrcpyVideoRegistry.pushFrame('frame-cap', frame('key', 1, { keyFrame: true })) + for (let index = 1; index <= SCRCPY_VIDEO_MAX_GOP_FRAMES + 10; index += 1) { + scrcpyVideoRegistry.pushFrame('frame-cap', frame(String(index), 1)) + } + + expect(replayFrames('frame-cap')).toEqual([]) + + scrcpyVideoRegistry.pushFrame('frame-cap', frame('recovery-key', 1, { keyFrame: true })) + expect(replayFrames('frame-cap').map((replayed) => replayed.pts)).toEqual(['recovery-key']) + }) + + it('sheds only cached deltas under per-device byte pressure', () => { + registerDevice('device-bytes') + const liveFrames: string[] = [] + const unsubscribe = scrcpyVideoRegistry.subscribe('device-bytes', (event) => { + if (event.type === 'frame') { + liveFrames.push(event.frame.pts) + } + }) + + scrcpyVideoRegistry.pushFrame('device-bytes', frame('config', MEBIBYTE, { config: true })) + scrcpyVideoRegistry.pushFrame('device-bytes', frame('key', 8 * MEBIBYTE, { keyFrame: true })) + for (let index = 1; index <= 5; index += 1) { + scrcpyVideoRegistry.pushFrame('device-bytes', frame(`delta-${index}`, 8 * MEBIBYTE)) + } + unsubscribe() + + expect(liveFrames).toEqual([ + 'config', + 'key', + 'delta-1', + 'delta-2', + 'delta-3', + 'delta-4', + 'delta-5' + ]) + expect(replayFrames('device-bytes').map((replayed) => replayed.pts)).toEqual(['config']) + expect(replayBytes('device-bytes')).toBeLessThanOrEqual( + SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE + ) + scrcpyVideoRegistry.pushFrame( + 'device-bytes', + frame('recovery-key', 8 * MEBIBYTE, { keyFrame: true }) + ) + expect(replayFrames('device-bytes').map((replayed) => replayed.pts)).toEqual([ + 'config', + 'recovery-key' + ]) + }) + + it('delivers an oversized frame live without retaining invalid replay', () => { + registerDevice('oversized') + const liveFrames: string[] = [] + const unsubscribe = scrcpyVideoRegistry.subscribe('oversized', (event) => { + if (event.type === 'frame') { + liveFrames.push(event.frame.pts) + } + }) + scrcpyVideoRegistry.pushFrame('oversized', frame('config', 1, { config: true })) + scrcpyVideoRegistry.pushFrame( + 'oversized', + frame('key', SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE, { keyFrame: true }) + ) + unsubscribe() + + expect(liveFrames).toEqual(['config', 'key']) + expect(replayFrames('oversized').map((replayed) => replayed.pts)).toEqual(['config']) + }) + + it('bounds replay bytes across devices while protecting the newest device', () => { + const perEntryBytes = 16 * MEBIBYTE + const deviceCount = Math.floor(SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL / perEntryBytes) + 1 + const deviceIds = Array.from({ length: deviceCount }, (_, index) => `aggregate-${index}`) + + for (const deviceId of deviceIds) { + registerDevice(deviceId) + scrcpyVideoRegistry.pushFrame(deviceId, frame('config', MEBIBYTE, { config: true })) + scrcpyVideoRegistry.pushFrame( + deviceId, + frame('key', perEntryBytes - MEBIBYTE, { keyFrame: true }) + ) + } + + const totalReplayBytes = deviceIds.reduce((total, deviceId) => total + replayBytes(deviceId), 0) + expect(totalReplayBytes).toBeLessThanOrEqual(SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL) + expect(replayFrames(deviceIds.at(-1)!).map((replayed) => replayed.pts)).toEqual([ + 'config', + 'key' + ]) + for (const deviceId of deviceIds) { + expect(replayFrames(deviceId).map((replayed) => replayed.pts)).toEqual( + expect.arrayContaining(['config']) + ) + } + }) + + it('cleans accounting before a reentrant close callback', () => { + const close = vi.fn(() => scrcpyVideoRegistry.stop('reentrant')) + registerDevice('reentrant', close) + scrcpyVideoRegistry.pushFrame( + 'reentrant', + frame('key', SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE, { keyFrame: true }) + ) + + scrcpyVideoRegistry.stop('reentrant') + + expect(close).toHaveBeenCalledOnce() + expect(scrcpyVideoRegistry.has('reentrant')).toBe(false) + }) + + it('bounds active registry entries without closing admitted streams', () => { + const closes: ReturnType[] = [] + for (let index = 0; index < SCRCPY_VIDEO_MAX_REGISTRY_ENTRIES; index += 1) { + const close = vi.fn() + closes.push(close) + registerDevice(`entry-${index}`, close) + } + + expect(() => registerDevice('entry-overflow')).toThrow(/active scrcpy video streams/) + for (const close of closes) { + expect(close).not.toHaveBeenCalled() + } + }) + + it('bounds subscriber callbacks and releases admission on unsubscribe', () => { + registerDevice('subscriber-cap') + const unsubscribes = Array.from({ length: SCRCPY_VIDEO_MAX_SUBSCRIBERS }, () => + scrcpyVideoRegistry.subscribe('subscriber-cap', () => {}) + ) + + expect(() => scrcpyVideoRegistry.subscribe('subscriber-cap', () => {})).toThrow( + /video subscribers/ + ) + unsubscribes[0]() + expect(() => scrcpyVideoRegistry.subscribe('subscriber-cap', () => {})).not.toThrow() + }) +}) diff --git a/src/main/emulator/scrcpy-video-registry.ts b/src/main/emulator/scrcpy-video-registry.ts index 417317ac550..47a4215f0e9 100644 --- a/src/main/emulator/scrcpy-video-registry.ts +++ b/src/main/emulator/scrcpy-video-registry.ts @@ -8,8 +8,13 @@ import type { ScrcpyVideoMeta } from './android/scrcpy-video-frame-parser' // scrcpy keyframes ~every 10s; high-motion content can otherwise buffer // hundreds of deltas. Cap the replayed GOP so memory stays bounded and a -// late subscriber isn't flooded — always keep the keyframe at index 0. -const MAX_GOP_FRAMES = 120 +// late subscriber isn't flooded. An overloaded GOP is shed as a unit because +// dropping an interior delta would make the retained suffix undecodable. +export const SCRCPY_VIDEO_MAX_GOP_FRAMES = 120 +export const SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE = 32 * 1024 * 1024 +export const SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL = 128 * 1024 * 1024 +export const SCRCPY_VIDEO_MAX_REGISTRY_ENTRIES = 16 +export const SCRCPY_VIDEO_MAX_SUBSCRIBERS = 8 export type ScrcpyVideoFrameMessage = { config: boolean @@ -30,13 +35,27 @@ type RegistryEntry = { gop: ScrcpyVideoFrameMessage[] subscribers: Set close: () => void + retainedBytes: number } class ScrcpyVideoRegistry { private readonly entries = new Map() + private retainedBytes = 0 + private subscriberCount = 0 register(deviceId: string, close: () => void): void { - this.entries.set(deviceId, { subscribers: new Set(), gop: [], close }) + if (!this.entries.has(deviceId) && this.entries.size >= SCRCPY_VIDEO_MAX_REGISTRY_ENTRIES) { + throw new Error( + `Orca can have at most ${SCRCPY_VIDEO_MAX_REGISTRY_ENTRIES} active scrcpy video streams.` + ) + } + this.stop(deviceId) + this.entries.set(deviceId, { + subscribers: new Set(), + gop: [], + close, + retainedBytes: 0 + }) } pushMeta(deviceId: string, meta: ScrcpyVideoMeta): void { @@ -56,20 +75,21 @@ class ScrcpyVideoRegistry { return } if (frame.config) { - entry.config = frame + this.replaceConfig(entry, frame) } else if (frame.keyFrame) { // A keyframe starts a fresh decodeable GOP; buffer it + the following // deltas so a late subscriber can decode immediately on replay. - entry.gop = [frame] + this.clearGop(entry) + entry.gop.push(frame) + this.adjustRetainedBytes(entry, frame.bytes.byteLength) } else if (entry.gop.length > 0) { // Only buffer deltas once a keyframe anchors the GOP (a delta alone is // undecodable); deltas before the first keyframe are still sent live below. entry.gop.push(frame) - // Drop the oldest delta (never index 0, the keyframe) so replay stays decodable. - if (entry.gop.length > MAX_GOP_FRAMES) { - entry.gop.splice(1, 1) - } + this.adjustRetainedBytes(entry, frame.bytes.byteLength) } + this.trimEntryReplay(entry) + this.trimAggregateReplay(deviceId) for (const subscriber of entry.subscribers) { subscriber({ type: 'frame', frame }) } @@ -82,6 +102,11 @@ class ScrcpyVideoRegistry { if (!entry) { return () => {} } + if (this.subscriberCount >= SCRCPY_VIDEO_MAX_SUBSCRIBERS) { + throw new Error( + `Orca can have at most ${SCRCPY_VIDEO_MAX_SUBSCRIBERS} scrcpy video subscribers.` + ) + } if (entry.meta) { subscriber({ type: 'meta', meta: entry.meta }) } @@ -92,8 +117,16 @@ class ScrcpyVideoRegistry { for (const frame of entry.gop) { subscriber({ type: 'frame', frame }) } + if (this.entries.get(deviceId) !== entry) { + return () => {} + } entry.subscribers.add(subscriber) - return () => entry.subscribers.delete(subscriber) + this.subscriberCount += 1 + return () => { + if (entry.subscribers.delete(subscriber)) { + this.subscriberCount -= 1 + } + } } stop(deviceId: string): void { @@ -101,14 +134,90 @@ class ScrcpyVideoRegistry { if (!entry) { return } - entry.close() - entry.subscribers.clear() this.entries.delete(deviceId) + this.subscriberCount -= entry.subscribers.size + entry.subscribers.clear() + this.clearGop(entry) + this.clearConfig(entry) + entry.close() } has(deviceId: string): boolean { return this.entries.has(deviceId) } + + private adjustRetainedBytes(entry: RegistryEntry, delta: number): void { + entry.retainedBytes += delta + this.retainedBytes += delta + } + + private replaceConfig(entry: RegistryEntry, frame: ScrcpyVideoFrameMessage): void { + this.clearConfig(entry) + entry.config = frame + this.adjustRetainedBytes(entry, frame.bytes.byteLength) + } + + private clearConfig(entry: RegistryEntry): void { + if (!entry.config) { + return + } + this.adjustRetainedBytes(entry, -entry.config.bytes.byteLength) + entry.config = undefined + } + + private clearGop(entry: RegistryEntry): void { + for (const frame of entry.gop) { + this.adjustRetainedBytes(entry, -frame.bytes.byteLength) + } + entry.gop = [] + } + + private trimEntryReplay(entry: RegistryEntry): void { + if ( + entry.gop.length > SCRCPY_VIDEO_MAX_GOP_FRAMES || + entry.retainedBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE + ) { + this.clearGop(entry) + } + if (entry.retainedBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE) { + this.clearConfig(entry) + } + } + + private trimAggregateReplay(preferredDeviceId: string): void { + if (this.retainedBytes <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL) { + return + } + const preferred = this.entries.get(preferredDeviceId) + + for (const [deviceId, entry] of this.entries) { + if (deviceId === preferredDeviceId) { + continue + } + if (this.retainedBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL && entry.gop.length > 0) { + this.clearGop(entry) + } + } + if ( + preferred && + this.retainedBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL && + preferred.gop.length > 0 + ) { + this.clearGop(preferred) + } + for (const [deviceId, entry] of this.entries) { + if (this.retainedBytes <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL) { + break + } + if (deviceId === preferredDeviceId) { + continue + } + this.clearConfig(entry) + } + if (preferred && this.retainedBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL) { + this.clearConfig(preferred) + } + } } export const scrcpyVideoRegistry = new ScrcpyVideoRegistry() diff --git a/src/main/emulator/serve-sim-accessibility-tree.test.ts b/src/main/emulator/serve-sim-accessibility-tree.test.ts index 48314017a87..2976554f0ee 100644 --- a/src/main/emulator/serve-sim-accessibility-tree.test.ts +++ b/src/main/emulator/serve-sim-accessibility-tree.test.ts @@ -4,7 +4,10 @@ const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) -import { requestServeSimAccessibilityTree } from './serve-sim-accessibility-tree' +import { + MAX_SERVE_SIM_AX_RESPONSE_BYTES, + requestServeSimAccessibilityTree +} from './serve-sim-accessibility-tree' const AX_URL = 'http://127.0.0.1:3100/ax' @@ -85,6 +88,25 @@ describe('requestServeSimAccessibilityTree', () => { }) }) + it('rejects oversized or excessively nested payloads before parsing', async () => { + netFetchMock.mockResolvedValueOnce( + new Response('[]', { + status: 200, + headers: { 'content-length': String(MAX_SERVE_SIM_AX_RESPONSE_BYTES + 1) } + }) + ) + await expect(requestServeSimAccessibilityTree(AX_URL)).rejects.toMatchObject({ + code: 'emulator_helper_failed' + }) + + netFetchMock.mockResolvedValueOnce( + new Response(`${'['.repeat(129)}0${']'.repeat(129)}`, { status: 200 }) + ) + await expect(requestServeSimAccessibilityTree(AX_URL)).rejects.toMatchObject({ + code: 'emulator_error' + }) + }) + it('maps a network failure to a helper error', async () => { netFetchMock.mockRejectedValue(new Error('connect ECONNREFUSED')) diff --git a/src/main/emulator/serve-sim-accessibility-tree.ts b/src/main/emulator/serve-sim-accessibility-tree.ts index dab53d0e382..9310d72b266 100644 --- a/src/main/emulator/serve-sim-accessibility-tree.ts +++ b/src/main/emulator/serve-sim-accessibility-tree.ts @@ -1,16 +1,22 @@ import { net } from 'electron' import { EmulatorError } from './emulator-errors' import { normalizeServeSimAxTree, type NormalizedAxNode } from './serve-sim-ax-normalization' +import { + API_RESPONSE_JSON_LIMITS, + readFetchResponseTextWithinLimit +} from '../../shared/fetch-response-body' +import { assertJsonTextStructureWithinLimits } from '../../shared/memory-safety/json-text-structure-limit' const AX_REQUEST_TIMEOUT_MS = 5_000 const MAX_ERROR_BODY_LENGTH = 512 +export const MAX_SERVE_SIM_AX_RESPONSE_BYTES = 16 * 1024 * 1024 export async function requestServeSimAccessibilityTree(axUrl: string): Promise { try { const response = await net.fetch(axUrl, { signal: AbortSignal.timeout(AX_REQUEST_TIMEOUT_MS) }) - const body = await response.text() + const body = await readFetchResponseTextWithinLimit(response, MAX_SERVE_SIM_AX_RESPONSE_BYTES) if (!response.ok) { const detail = body.slice(0, MAX_ERROR_BODY_LENGTH) || response.statusText const retry = response.status === 503 ? ' Accessibility may still be warming up; retry.' : '' @@ -22,6 +28,7 @@ export async function requestServeSimAccessibilityTree(axUrl: string): Promise { + return { + device: udid, + streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3100/ws' + } +} + +afterEach(() => { + for (const filePath of stateFiles.splice(0)) { + rmSync(filePath, { force: true }) + } +}) + describe('parseServeSimDetachedSession', () => { it('uses serve-sim streamUrl when present', () => { const info = parseServeSimDetachedSession( @@ -56,6 +83,27 @@ describe('parseServeSimDetachedSession', () => { expect(info.streamUrl).toBe('http://127.0.0.1:3100/stream.mjpeg') }) + + it('reads a helper PID state file at the exact byte boundary', () => { + const udid = `orca-boundary-${process.pid}-${Date.now()}` + const statePath = stateFileFor(udid) + const state = '{"pid":4321}' + writeFileSync( + statePath, + state + ' '.repeat(SERVE_SIM_STATE_FILE_MAX_BYTES - Buffer.byteLength(state)) + ) + + expect(parseServeSimDetachedSession(detachedPayload(udid), udid).helperPid).toBe(4321) + }) + + it('ignores a sparse helper PID state file one byte over the boundary', () => { + const udid = `orca-oversized-${process.pid}-${Date.now()}` + const statePath = stateFileFor(udid) + writeFileSync(statePath, '{"pid":4321}') + truncateSync(statePath, SERVE_SIM_STATE_FILE_MAX_BYTES + 1) + + expect(parseServeSimDetachedSession(detachedPayload(udid), udid).helperPid).toBeUndefined() + }) }) describe('deriveAxUrlFromStreamUrl', () => { diff --git a/src/main/emulator/serve-sim-detached-session.ts b/src/main/emulator/serve-sim-detached-session.ts index d45bde41ff0..3dbb4f4af18 100644 --- a/src/main/emulator/serve-sim-detached-session.ts +++ b/src/main/emulator/serve-sim-detached-session.ts @@ -1,10 +1,11 @@ -import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' import { EmulatorError } from './emulator-errors' import type { EmulatorSessionInfo } from './emulator-types' const MJPEG_STREAM_SUFFIX = '/stream.mjpeg' +export const SERVE_SIM_STATE_FILE_MAX_BYTES = 8 * 1024 function streamUrlFromServeSimUrl(url: string): string { return url.endsWith(MJPEG_STREAM_SUFFIX) ? url : `${url.replace(/\/$/, '')}${MJPEG_STREAM_SUFFIX}` @@ -42,11 +43,11 @@ export function parseServeSimDetachedSession(raw: unknown, udid: string): Emulat } try { const statePath = join(tmpdir(), 'serve-sim', `server-${info.deviceUdid}.json`) - if (existsSync(statePath)) { - const state = JSON.parse(readFileSync(statePath, 'utf8')) as { pid?: unknown } - if (typeof state.pid === 'number') { - info.helperPid = state.pid - } + const state = JSON.parse( + readNodeFileSyncWithinLimit(statePath, SERVE_SIM_STATE_FILE_MAX_BYTES).buffer.toString('utf8') + ) as { pid?: unknown } + if (typeof state.pid === 'number') { + info.helperPid = state.pid } } catch {} return info diff --git a/src/main/emulator/serve-sim-execution.ts b/src/main/emulator/serve-sim-execution.ts index 5bdf0e34cc2..9207fdfd913 100644 --- a/src/main/emulator/serve-sim-execution.ts +++ b/src/main/emulator/serve-sim-execution.ts @@ -1,16 +1,9 @@ import { execFile } from 'node:child_process' -import { - accessSync, - chmodSync, - constants, - existsSync, - mkdirSync, - readFileSync, - writeFileSync -} from 'node:fs' +import { accessSync, chmodSync, constants, existsSync, mkdirSync, writeFileSync } from 'node:fs' import { app } from 'electron' import { platform, tmpdir } from 'node:os' import { delimiter, dirname, join } from 'node:path' +import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' import { EmulatorError } from './emulator-errors' import { materializeServeSimRuntime } from './serve-sim-runtime-materializer' @@ -45,8 +38,10 @@ function ensureMacOpenShim(): string | null { } try { mkdirSync(MAC_OPEN_SHIM_DIR, { recursive: true }) - const current = existsSync(MAC_OPEN_SHIM_PATH) ? readFileSync(MAC_OPEN_SHIM_PATH, 'utf8') : '' - if (current !== MAC_OPEN_SHIM) { + if ( + !existsSync(MAC_OPEN_SHIM_PATH) || + !nodeFileContentsEqualSync(MAC_OPEN_SHIM_PATH, MAC_OPEN_SHIM) + ) { writeFileSync(MAC_OPEN_SHIM_PATH, MAC_OPEN_SHIM, { mode: 0o755 }) } chmodSync(MAC_OPEN_SHIM_PATH, 0o755) diff --git a/src/main/filesystem-provider-bounded-text.test.ts b/src/main/filesystem-provider-bounded-text.test.ts new file mode 100644 index 00000000000..71acf33d3ed --- /dev/null +++ b/src/main/filesystem-provider-bounded-text.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IFilesystemProvider } from './providers/types' +import { readFilesystemProviderBoundedText } from './filesystem-provider-bounded-text' + +const limits = { maxBytes: 8, maxCodeUnits: 8 } + +function provider(args: { + size: number + content?: string + isBinary?: boolean +}): IFilesystemProvider { + return { + stat: vi.fn().mockResolvedValue({ + size: args.size, + type: 'file', + mtime: 0 + }), + readFile: vi.fn().mockResolvedValue({ + content: args.content ?? '', + isBinary: args.isBinary ?? false + }) + } as unknown as IFilesystemProvider +} + +describe('readFilesystemProviderBoundedText', () => { + it('reads a file at the exact byte boundary', async () => { + const fsProvider = provider({ size: limits.maxBytes, content: '12345678' }) + + await expect( + readFilesystemProviderBoundedText(fsProvider, '/repo/orca.yaml', limits) + ).resolves.toEqual({ kind: 'text', content: '12345678' }) + expect(fsProvider.readFile).toHaveBeenCalledOnce() + }) + + it('rejects a +1 stat before remote materialization', async () => { + const fsProvider = provider({ size: limits.maxBytes + 1, content: 'not read' }) + + await expect( + readFilesystemProviderBoundedText(fsProvider, '/repo/orca.yaml', limits) + ).resolves.toEqual({ kind: 'oversized' }) + expect(fsProvider.readFile).not.toHaveBeenCalled() + }) + + it('rechecks UTF-8 bytes after a stat/read race', async () => { + const fsProvider = provider({ size: 2, content: '🐋🐋🐋' }) + + await expect( + readFilesystemProviderBoundedText(fsProvider, '/repo/orca.yaml', limits) + ).resolves.toEqual({ kind: 'oversized' }) + }) + + it('rechecks code units after a stat/read race', async () => { + const fsProvider = provider({ size: 2, content: '123456789' }) + + await expect( + readFilesystemProviderBoundedText(fsProvider, '/repo/orca.yaml', limits) + ).resolves.toEqual({ kind: 'oversized' }) + }) + + it('preserves binary classification', async () => { + const fsProvider = provider({ size: 2, content: 'xx', isBinary: true }) + + await expect( + readFilesystemProviderBoundedText(fsProvider, '/repo/orca.yaml', limits) + ).resolves.toEqual({ kind: 'binary' }) + }) +}) diff --git a/src/main/filesystem-provider-bounded-text.ts b/src/main/filesystem-provider-bounded-text.ts new file mode 100644 index 00000000000..231abf606a3 --- /dev/null +++ b/src/main/filesystem-provider-bounded-text.ts @@ -0,0 +1,34 @@ +import { measureUtf8ByteLength } from '../shared/memory-safety/utf8-byte-limits' +import type { IFilesystemProvider } from './providers/types' + +export type FilesystemProviderBoundedText = + | { kind: 'text'; content: string } + | { kind: 'binary' } + | { kind: 'oversized' } + +export async function readFilesystemProviderBoundedText( + provider: IFilesystemProvider, + filePath: string, + limits: { maxBytes: number; maxCodeUnits: number } +): Promise { + const fileStat = await provider.stat(filePath) + if ( + !Number.isSafeInteger(fileStat.size) || + fileStat.size < 0 || + fileStat.size > limits.maxBytes + ) { + return { kind: 'oversized' } + } + + const result = await provider.readFile(filePath) + if (result.isBinary) { + return { kind: 'binary' } + } + if ( + result.content.length > limits.maxCodeUnits || + measureUtf8ByteLength(result.content, { stopAfterBytes: limits.maxBytes }).exceededLimit + ) { + return { kind: 'oversized' } + } + return { kind: 'text', content: result.content } +} diff --git a/src/main/generated-node-bounded-file-reader.test.ts b/src/main/generated-node-bounded-file-reader.test.ts new file mode 100644 index 00000000000..462be297bb7 --- /dev/null +++ b/src/main/generated-node-bounded-file-reader.test.ts @@ -0,0 +1,86 @@ +import { + closeSync, + mkdtempSync, + openSync, + readSync, + rmSync, + truncateSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runInNewContext } from 'node:vm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + GENERATED_NODE_MANAGED_FILE_MAX_BYTES, + getGeneratedNodeBoundedFileReaderSourceLines +} from './generated-node-bounded-file-reader' + +type GeneratedReader = ( + fs: { + closeSync: typeof closeSync + openSync: typeof openSync + readSync: typeof readSync + }, + path: string +) => string + +const temporaryDirectories: string[] = [] + +function buildGeneratedReader(): GeneratedReader { + const module = { exports: undefined as GeneratedReader | undefined } + runInNewContext( + [ + ...getGeneratedNodeBoundedFileReaderSourceLines(), + 'module.exports = readOrcaManagedFileWithinLimit;' + ].join('\n'), + { Buffer, module } + ) + if (!module.exports) { + throw new Error('generated bounded reader did not export') + } + return module.exports +} + +function createFile(content: string): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-generated-node-read-')) + temporaryDirectories.push(directory) + const path = join(directory, 'managed-file') + writeFileSync(path, content) + return path +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('generated Node bounded file reader', () => { + it('preserves ordinary managed-file contents', () => { + const path = createFile('stable 🐋 endpoint') + + expect(buildGeneratedReader()({ openSync, readSync, closeSync }, path)).toBe( + 'stable 🐋 endpoint' + ) + }) + + it('accepts a file at the exact byte cap', () => { + const path = createFile('x'.repeat(GENERATED_NODE_MANAGED_FILE_MAX_BYTES)) + + expect( + Buffer.byteLength(buildGeneratedReader()({ openSync, readSync, closeSync }, path), 'utf8') + ).toBe(GENERATED_NODE_MANAGED_FILE_MAX_BYTES) + }) + + it('rejects a sparse oversized file and always closes its descriptor', () => { + const path = createFile('') + truncateSync(path, GENERATED_NODE_MANAGED_FILE_MAX_BYTES + 1) + const close = vi.fn(closeSync) + + expect(() => buildGeneratedReader()({ openSync, readSync, closeSync: close }, path)).toThrow( + `Managed Orca file exceeds ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES} bytes` + ) + expect(close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/generated-node-bounded-file-reader.ts b/src/main/generated-node-bounded-file-reader.ts new file mode 100644 index 00000000000..7f483557e5e --- /dev/null +++ b/src/main/generated-node-bounded-file-reader.ts @@ -0,0 +1,30 @@ +export const GENERATED_NODE_MANAGED_FILE_MAX_BYTES = 64 * 1024 + +// Why: emitted runtimes cannot import Orca's reader; fixed capacity also catches growth after stat. +export function getGeneratedNodeBoundedFileReaderSourceLines(options?: { + typed?: boolean +}): string[] { + const signature = options?.typed + ? `function readOrcaManagedFileWithinLimit(fs: any, path: string, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES}): string {` + : `function readOrcaManagedFileWithinLimit(fs, path, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES}) {` + return [ + signature, + " const descriptor = fs.openSync(path, 'r');", + ' try {', + ' const buffer = Buffer.allocUnsafe(maxBytes + 1);', + ' let offset = 0;', + ' while (offset < buffer.length) {', + ' const bytesRead = fs.readSync(descriptor, buffer, offset, buffer.length - offset, null);', + ' if (bytesRead === 0) break;', + ' offset += bytesRead;', + ' }', + ' if (offset > maxBytes) {', + " throw Object.assign(new Error('Managed Orca file exceeds ' + maxBytes + ' bytes'), { code: 'EFBIG' });", + ' }', + " return buffer.toString('utf8', 0, offset);", + ' } finally {', + ' fs.closeSync(descriptor);', + ' }', + '}' + ] +} diff --git a/src/main/ghostty/index.test.ts b/src/main/ghostty/index.test.ts index 37d059c91fd..bc9734bb158 100644 --- a/src/main/ghostty/index.test.ts +++ b/src/main/ghostty/index.test.ts @@ -1,22 +1,36 @@ import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' +import type * as BoundedFileReader from '../../shared/memory-safety/node-bounded-file-reader' +import type * as GhosttyParser from './parser' import { afterEach, describe, expect, it, vi } from 'vitest' -const { statMock, readFileMock } = vi.hoisted(() => ({ +const { statMock, readNodeFileWithinLimitMock, parseGhosttyConfigMock } = vi.hoisted(() => ({ statMock: vi.fn(), - readFileMock: vi.fn() + readNodeFileWithinLimitMock: vi.fn(), + parseGhosttyConfigMock: vi.fn() })) vi.mock('fs/promises', () => ({ - stat: statMock, - readFile: readFileMock + stat: statMock })) +vi.mock('../../shared/memory-safety/node-bounded-file-reader', async (importOriginal) => ({ + ...(await importOriginal()), + readNodeFileWithinLimit: readNodeFileWithinLimitMock +})) + +vi.mock('./parser', async (importOriginal) => { + const actual = await importOriginal() + parseGhosttyConfigMock.mockImplementation(actual.parseGhosttyConfig) + return { ...actual, parseGhosttyConfig: parseGhosttyConfigMock } +}) + vi.mock('os', () => ({ platform: vi.fn(() => 'darwin'), homedir: vi.fn(() => '/Users/alice') })) +import { NodeFileReadTooLargeError } from '../../shared/memory-safety/node-bounded-file-reader' import { previewGhosttyImport } from './index' const originalXdgConfigHome = process.env.XDG_CONFIG_HOME @@ -36,6 +50,13 @@ function createStore(settings: Record = {}): Store { } as Store } +function fileRead(content: string, size = Buffer.byteLength(content)) { + return { + buffer: Buffer.from(content), + stats: { isFile: () => true, size } + } +} + describe('previewGhosttyImport', () => { it('returns found false when no config exists', async () => { statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) @@ -52,11 +73,13 @@ describe('previewGhosttyImport', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue(` + readNodeFileWithinLimitMock.mockResolvedValue( + fileRead(` font-family = JetBrains Mono font-size = 14 background = #1a1a1a `) + ) const result = await previewGhosttyImport( createStore({ @@ -88,12 +111,12 @@ background = #1a1a1a } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockImplementation(async (p: string) => { + readNodeFileWithinLimitMock.mockImplementation(async (p: string) => { if (p === '/Users/alice/.config/ghostty/config.ghostty') { - return 'font-size = 22\nbackground = #1a1a1a\n' + return fileRead('font-size = 22\nbackground = #1a1a1a\n') } if (p === '/Users/alice/.config/ghostty/config') { - return 'font-family = JetBrains Mono\nfont-size = 18\n' + return fileRead('font-family = JetBrains Mono\nfont-size = 18\n') } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) @@ -121,7 +144,7 @@ background = #1a1a1a } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue('font-family = Menlo\nfont-size = 12\n') + readNodeFileWithinLimitMock.mockResolvedValue(fileRead('font-family = Menlo\nfont-size = 12\n')) const result = await previewGhosttyImport( createStore({ @@ -142,7 +165,9 @@ background = #1a1a1a } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue('background = #1a1a1a\nforeground = #e0e0e0\n') + readNodeFileWithinLimitMock.mockResolvedValue( + fileRead('background = #1a1a1a\nforeground = #e0e0e0\n') + ) const result = await previewGhosttyImport( createStore({ @@ -162,7 +187,9 @@ background = #1a1a1a } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue('background = #1a1a1a\nforeground = #e0e0e0\n') + readNodeFileWithinLimitMock.mockResolvedValue( + fileRead('background = #1a1a1a\nforeground = #e0e0e0\n') + ) const result = await previewGhosttyImport( createStore({ @@ -192,7 +219,7 @@ background = #1a1a1a } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue('font-family = JetBrains Mono\n') + readNodeFileWithinLimitMock.mockResolvedValue(fileRead('font-family = JetBrains Mono\n')) // Why: Replace timer globals temporarily to detect any polling setup. const originalSetInterval = globalThis.setInterval @@ -212,4 +239,44 @@ background = #1a1a1a expect(setIntervalMock).not.toHaveBeenCalled() expect(setTimeoutMock).not.toHaveBeenCalled() }) + + it('accepts a config at the exact byte limit', async () => { + const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config' + statMock.mockImplementation(async (p: string) => { + if (p === configPath) { + return { isFile: () => true, size: 1_000_000 } + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readNodeFileWithinLimitMock.mockResolvedValue(fileRead('font-size = 14\n', 1_000_000)) + + const result = await previewGhosttyImport(createStore()) + + expect(result.found).toBe(true) + expect(result.diff).toEqual({ terminalFontSize: 14 }) + expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith(configPath, 1_000_000) + }) + + it('rejects config growth beyond the limit before parsing', async () => { + const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config' + statMock.mockImplementation(async (p: string) => { + if (p === configPath) { + return { isFile: () => true, size: 128 } + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readNodeFileWithinLimitMock.mockRejectedValue( + new NodeFileReadTooLargeError(1_000_001, 1_000_000) + ) + + const result = await previewGhosttyImport(createStore()) + + expect(result).toMatchObject({ + found: false, + diff: {}, + unsupportedKeys: [], + error: 'Config file is too large to import (1000001 bytes, limit 1000000).' + }) + expect(parseGhosttyConfigMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ghostty/index.ts b/src/main/ghostty/index.ts index 8557193fe9f..940f5904725 100644 --- a/src/main/ghostty/index.ts +++ b/src/main/ghostty/index.ts @@ -1,6 +1,9 @@ -import { readFile, stat } from 'node:fs/promises' import { platform } from 'node:os' import type { GlobalSettings, GhosttyImportPreview } from '../../shared/types' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' import type { Store } from '../persistence' import { findGhosttyConfigPaths } from './discovery' import { parseGhosttyConfig } from './parser' @@ -110,17 +113,25 @@ export async function previewGhosttyImport(store: Store): Promise MAX_CONFIG_BYTES) { + const result = await readNodeFileWithinLimit(configPath, MAX_CONFIG_BYTES) + if (!result.stats.isFile()) { return { found: false, diff: {}, unsupportedKeys: [], - error: `Config file is too large to import (${info.size} bytes, limit ${MAX_CONFIG_BYTES}).` + error: 'Could not read config: Config path is not a file' } } - content = await readFile(configPath, 'utf-8') + content = result.buffer.toString('utf8') } catch (err) { + if (err instanceof NodeFileReadTooLargeError) { + return { + found: false, + diff: {}, + unsupportedKeys: [], + error: `Config file is too large to import (${err.observedBytes} bytes, limit ${MAX_CONFIG_BYTES}).` + } + } const message = err instanceof Error ? err.message : 'Could not read config file' return { found: false, diff --git a/src/main/ghostty/theme-import.test.ts b/src/main/ghostty/theme-import.test.ts index c2d2ec61ab9..19a8840f20f 100644 --- a/src/main/ghostty/theme-import.test.ts +++ b/src/main/ghostty/theme-import.test.ts @@ -1,15 +1,20 @@ import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' +import type * as BoundedFileReader from '../../shared/memory-safety/node-bounded-file-reader' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { statMock, readFileMock } = vi.hoisted(() => ({ +const { statMock, readNodeFileWithinLimitMock } = vi.hoisted(() => ({ statMock: vi.fn(), - readFileMock: vi.fn() + readNodeFileWithinLimitMock: vi.fn() })) vi.mock('fs/promises', () => ({ - stat: statMock, - readFile: readFileMock + stat: statMock +})) + +vi.mock('../../shared/memory-safety/node-bounded-file-reader', async (importOriginal) => ({ + ...(await importOriginal()), + readNodeFileWithinLimit: readNodeFileWithinLimitMock })) vi.mock('os', () => ({ @@ -47,6 +52,13 @@ function createStore(settings: Record = {}): Store { } as Store } +function fileRead(content: string) { + return { + buffer: Buffer.from(content), + stats: { isFile: () => true, size: Buffer.byteLength(content) } + } +} + describe('previewGhosttyImport theme references', () => { it('resolves a theme reference into color overrides', async () => { const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config' @@ -57,11 +69,11 @@ describe('previewGhosttyImport theme references', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockImplementation(async (p: string) => { + readNodeFileWithinLimitMock.mockImplementation(async (p: string) => { if (p === themePath) { - return 'palette = 1=#d54e53\nbackground = #000000\nforeground = #eaeaea\n' + return fileRead('palette = 1=#d54e53\nbackground = #000000\nforeground = #eaeaea\n') } - return 'theme = Tomorrow Night Bright\nfont-size = 14\n' + return fileRead('theme = Tomorrow Night Bright\nfont-size = 14\n') }) const result = await previewGhosttyImport(createStore()) @@ -86,11 +98,11 @@ describe('previewGhosttyImport theme references', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockImplementation(async (p: string) => { + readNodeFileWithinLimitMock.mockImplementation(async (p: string) => { if (p === themePath) { - return 'palette = 1=#d54e53\npalette = 2=#b9ca4a\nbackground = #000000\n' + return fileRead('palette = 1=#d54e53\npalette = 2=#b9ca4a\nbackground = #000000\n') } - return 'theme = night\nbackground = #101010\npalette = 1=#ff0000\n' + return fileRead('theme = night\nbackground = #101010\npalette = 1=#ff0000\n') }) const result = await previewGhosttyImport(createStore()) @@ -115,11 +127,11 @@ describe('previewGhosttyImport theme references', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockImplementation(async (p: string) => { + readNodeFileWithinLimitMock.mockImplementation(async (p: string) => { if (p === themePath) { - return 'background = #202020\nforeground = #f0f0f0\n' + return fileRead('background = #202020\nforeground = #f0f0f0\n') } - return `theme = ${themePath}\n` + return fileRead(`theme = ${themePath}\n`) }) const result = await previewGhosttyImport(createStore()) @@ -145,7 +157,7 @@ describe('previewGhosttyImport theme references', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue('theme = Missing Theme\n') + readNodeFileWithinLimitMock.mockResolvedValue(fileRead('theme = Missing Theme\n')) const result = await previewGhosttyImport(createStore()) @@ -161,7 +173,9 @@ describe('previewGhosttyImport theme references', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue('theme = light:Tomorrow,dark:Tomorrow Night\n') + readNodeFileWithinLimitMock.mockResolvedValue( + fileRead('theme = light:Tomorrow,dark:Tomorrow Night\n') + ) const result = await previewGhosttyImport(createStore()) diff --git a/src/main/ghostty/theme-resolution.test.ts b/src/main/ghostty/theme-resolution.test.ts index 607e69a39e8..282a1c4efd7 100644 --- a/src/main/ghostty/theme-resolution.test.ts +++ b/src/main/ghostty/theme-resolution.test.ts @@ -1,20 +1,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as BoundedFileReader from '../../shared/memory-safety/node-bounded-file-reader' +import type * as GhosttyParser from './parser' -const { statMock, readFileMock } = vi.hoisted(() => ({ +const { statMock, readNodeFileWithinLimitMock, parseGhosttyConfigMock } = vi.hoisted(() => ({ statMock: vi.fn(), - readFileMock: vi.fn() + readNodeFileWithinLimitMock: vi.fn(), + parseGhosttyConfigMock: vi.fn() })) vi.mock('fs/promises', () => ({ - stat: statMock, - readFile: readFileMock + stat: statMock })) +vi.mock('../../shared/memory-safety/node-bounded-file-reader', async (importOriginal) => ({ + ...(await importOriginal()), + readNodeFileWithinLimit: readNodeFileWithinLimitMock +})) + +vi.mock('./parser', async (importOriginal) => { + const actual = await importOriginal() + parseGhosttyConfigMock.mockImplementation(actual.parseGhosttyConfig) + return { ...actual, parseGhosttyConfig: parseGhosttyConfigMock } +}) + vi.mock('os', () => ({ platform: vi.fn(() => 'darwin'), homedir: vi.fn(() => '/Users/alice') })) +import { NodeFileReadTooLargeError } from '../../shared/memory-safety/node-bounded-file-reader' import { getGhosttyThemeSearchDirs, resolveGhosttyThemeColors } from './theme-resolution' const originalXdgConfigHome = process.env.XDG_CONFIG_HOME @@ -49,6 +63,13 @@ selection-background = #424242 selection-foreground = #eaeaea ` +function fileRead(content: string, size = Buffer.byteLength(content)) { + return { + buffer: Buffer.from(content), + stats: { isFile: () => true, size } + } +} + describe('getGhosttyThemeSearchDirs', () => { it('probes XDG, then the Ghostty.app resources dir', () => { delete process.env.XDG_CONFIG_HOME @@ -80,7 +101,7 @@ describe('resolveGhosttyThemeColors', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue(THEME_FILE) + readNodeFileWithinLimitMock.mockResolvedValue(fileRead(THEME_FILE)) const result = await resolveGhosttyThemeColors('Tomorrow Night Bright') expect(result).toEqual({ @@ -100,7 +121,7 @@ describe('resolveGhosttyThemeColors', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue('background = #1d1f21') + readNodeFileWithinLimitMock.mockResolvedValue(fileRead('background = #1d1f21')) const result = await resolveGhosttyThemeColors('Tomorrow') expect(result).toEqual({ background: '#1d1f21' }) @@ -113,7 +134,7 @@ describe('resolveGhosttyThemeColors', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue('background = #1d1f21') + readNodeFileWithinLimitMock.mockResolvedValue(fileRead('background = #1d1f21')) const result = await resolveGhosttyThemeColors('/Users/alice/themes/work') expect(result).toEqual({ background: '#1d1f21' }) @@ -127,7 +148,7 @@ describe('resolveGhosttyThemeColors', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readFileMock.mockResolvedValue('background = #101010\nfont-size = 20') + readNodeFileWithinLimitMock.mockResolvedValue(fileRead('background = #101010\nfont-size = 20')) const result = await resolveGhosttyThemeColors('custom') expect(result).toEqual({ background: '#101010' }) @@ -149,7 +170,7 @@ describe('resolveGhosttyThemeColors', () => { it('rejects oversized theme files', async () => { statMock.mockImplementation(async () => ({ isFile: () => true, size: 10_000_000 })) expect(await resolveGhosttyThemeColors('huge')).toBeNull() - expect(readFileMock).not.toHaveBeenCalled() + expect(readNodeFileWithinLimitMock).not.toHaveBeenCalled() expect(statMock).toHaveBeenCalledTimes(1) }) @@ -165,7 +186,35 @@ describe('resolveGhosttyThemeColors', () => { }) expect(await resolveGhosttyThemeColors('night')).toBeNull() - expect(readFileMock).not.toHaveBeenCalled() + expect(readNodeFileWithinLimitMock).not.toHaveBeenCalled() expect(statMock).toHaveBeenCalledTimes(1) }) + + it('accepts a theme at the exact byte limit', async () => { + const themePath = '/Users/alice/.config/ghostty/themes/boundary' + statMock.mockImplementation(async (p: string) => { + if (p === themePath) { + return { isFile: () => true, size: 262_144 } + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readNodeFileWithinLimitMock.mockResolvedValue(fileRead('background = #1d1f21', 262_144)) + + expect(await resolveGhosttyThemeColors('boundary')).toEqual({ background: '#1d1f21' }) + expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith(themePath, 262_144) + }) + + it('rejects theme growth beyond the limit before parsing', async () => { + const themePath = '/Users/alice/.config/ghostty/themes/growing' + statMock.mockImplementation(async (p: string) => { + if (p === themePath) { + return { isFile: () => true, size: 128 } + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readNodeFileWithinLimitMock.mockRejectedValue(new NodeFileReadTooLargeError(262_145, 262_144)) + + expect(await resolveGhosttyThemeColors('growing')).toBeNull() + expect(parseGhosttyConfigMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ghostty/theme-resolution.ts b/src/main/ghostty/theme-resolution.ts index eedb2c1592d..8176f53656d 100644 --- a/src/main/ghostty/theme-resolution.ts +++ b/src/main/ghostty/theme-resolution.ts @@ -1,6 +1,7 @@ import { homedir, platform } from 'node:os' import path from 'node:path' -import { readFile, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' import { parseGhosttyConfig } from './parser' // Why: theme files ship a few dozen short lines; anything larger is not a @@ -86,7 +87,11 @@ async function readThemeColors(themePath: string): Promise { if (!info.isFile() || info.size > MAX_THEME_BYTES) { return { status: 'invalid' } } - content = await readFile(themePath, 'utf-8') + const result = await readNodeFileWithinLimit(themePath, MAX_THEME_BYTES) + if (!result.stats.isFile()) { + return { status: 'invalid' } + } + content = result.buffer.toString('utf8') } catch (err) { return { status: isMissingThemeError(err) ? 'missing' : 'invalid' } } diff --git a/src/main/git/gh-rate-limit-breaker.ts b/src/main/git/gh-rate-limit-breaker.ts index 8bc4276490a..36c15077490 100644 --- a/src/main/git/gh-rate-limit-breaker.ts +++ b/src/main/git/gh-rate-limit-breaker.ts @@ -12,6 +12,8 @@ * the github rate-limit prober can use it without an import cycle. */ +import { cacheIdentityDigest } from '../cache-identity-digest' + export type GhRateLimitBucket = 'core' | 'search' | 'graphql' // Why: a primary 403 does not carry the reset time. The search window resets @@ -62,7 +64,7 @@ const blockedUntilMsByScopeAndBucket = new Map() let resetProbe: ((bucket: GhRateLimitBucket, scope: string) => void) | null = null function breakerKey(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): string { - return `${scope}\0${bucket}` + return cacheIdentityDigest([scope, bucket]) } // gh api flags that take a separate value, so the endpoint arg can be found. diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts index 0d499aea163..c26d68350f2 100644 --- a/src/main/git/git-capability-state.test.ts +++ b/src/main/git/git-capability-state.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it } from 'vitest' import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache, - getSshGitCapabilityCache + getSshGitCapabilityCache, + LOCAL_GIT_CAPABILITY_HOST_KEY_MAX_BYTES, + LOCAL_GIT_CAPABILITY_HOST_MAX_ENTRIES } from './git-capability-state' describe('Git capability execution-host state', () => { @@ -34,4 +36,27 @@ describe('Git capability execution-host state', () => { getSshGitCapabilityCache(replacementProvider) ) }) + + it('evicts the oldest local execution host after the host ceiling', () => { + const oldest = getLocalGitCapabilityCache({ wslDistro: 'Distro-0' }) + let newest = oldest + for (let index = 1; index <= LOCAL_GIT_CAPABILITY_HOST_MAX_ENTRIES; index++) { + newest = getLocalGitCapabilityCache({ wslDistro: `Distro-${index}` }) + } + + expect(getLocalGitCapabilityCache({ wslDistro: 'Distro-0' })).not.toBe(oldest) + expect( + getLocalGitCapabilityCache({ + wslDistro: `Distro-${LOCAL_GIT_CAPABILITY_HOST_MAX_ENTRIES}` + }) + ).toBe(newest) + }) + + it('does not retain oversized execution-host keys', () => { + const oversizedDistro = 'x'.repeat(LOCAL_GIT_CAPABILITY_HOST_KEY_MAX_BYTES + 1) + + expect(getLocalGitCapabilityCache({ wslDistro: oversizedDistro })).not.toBe( + getLocalGitCapabilityCache({ wslDistro: oversizedDistro }) + ) + }) }) diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts index aec7a369a26..d4de5f3e6e9 100644 --- a/src/main/git/git-capability-state.ts +++ b/src/main/git/git-capability-state.ts @@ -1,4 +1,5 @@ import { GitCapabilityCache } from '../../shared/git-capability-cache' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' import { parseWslUncPath } from '../../shared/wsl-paths' type LocalGitCapabilityTarget = { @@ -6,6 +7,8 @@ type LocalGitCapabilityTarget = { wslDistro?: string } +export const LOCAL_GIT_CAPABILITY_HOST_MAX_ENTRIES = 64 +export const LOCAL_GIT_CAPABILITY_HOST_KEY_MAX_BYTES = 4 * 1024 const localCapabilitiesByExecutionHost = new Map() // Why: reconnecting creates a new provider, while concurrent IPC/runtime users // of one SSH connection must share the same remote Git capability results. @@ -21,10 +24,24 @@ export function getLocalGitCapabilityCache( target: LocalGitCapabilityTarget = {} ): GitCapabilityCache { const executionHost = getLocalGitExecutionHostKey(target) + if ( + measureUtf8ByteLength(executionHost, { + stopAfterBytes: LOCAL_GIT_CAPABILITY_HOST_KEY_MAX_BYTES + }).exceededLimit + ) { + return new GitCapabilityCache() + } let cache = localCapabilitiesByExecutionHost.get(executionHost) if (!cache) { cache = new GitCapabilityCache() localCapabilitiesByExecutionHost.set(executionHost, cache) + while (localCapabilitiesByExecutionHost.size > LOCAL_GIT_CAPABILITY_HOST_MAX_ENTRIES) { + const oldestHost = localCapabilitiesByExecutionHost.keys().next().value + if (oldestHost === undefined) { + break + } + localCapabilitiesByExecutionHost.delete(oldestHost) + } } return cache } diff --git a/src/main/git/huge-folder-ignore.test.ts b/src/main/git/huge-folder-ignore.test.ts index 60b107ce34c..74f93e71474 100644 --- a/src/main/git/huge-folder-ignore.test.ts +++ b/src/main/git/huge-folder-ignore.test.ts @@ -84,6 +84,35 @@ describe('appendFolderToGitignore', () => { expect(wrote).toBe(false) }) + it('streams a large single-line file while preserving append semantics', async () => { + const gitignorePath = path.join(dir, '.gitignore') + await fs.writeFile(gitignorePath, 'x') + await fs.truncate(gitignorePath, 8 * 1024 * 1024) + + expect(await appendFolderToGitignore(dir, 'dist')).toBe(true) + + const handle = await fs.open(gitignorePath, 'r') + try { + const info = await handle.stat() + const tail = Buffer.alloc(7) + await handle.read(tail, 0, tail.length, info.size - tail.length) + expect(tail.toString('utf8')).toBe('\ndist/\n') + } finally { + await handle.close() + } + }) + + it('preserves trimmed-line matching across streamed chunks', async () => { + const gitignorePath = path.join(dir, '.gitignore') + await fs.writeFile(gitignorePath, 'x') + await fs.truncate(gitignorePath, 8 * 1024 * 1024) + await fs.appendFile(gitignorePath, '\n\u00a0node_modules/\u00a0\n') + const sizeBefore = (await fs.stat(gitignorePath)).size + + expect(await appendFolderToGitignore(dir, 'node_modules')).toBe(false) + expect((await fs.stat(gitignorePath)).size).toBe(sizeBefore) + }) + it('rejects folder names outside the known allowlist (injection guard)', async () => { await expect(appendFolderToGitignore(dir, 'node_modules\n/etc/passwd')).rejects.toThrow( /Refusing to add/ diff --git a/src/main/git/huge-folder-ignore.ts b/src/main/git/huge-folder-ignore.ts index a115a38f6d8..507def3fc1f 100644 --- a/src/main/git/huge-folder-ignore.ts +++ b/src/main/git/huge-folder-ignore.ts @@ -1,6 +1,7 @@ -import { existsSync } from 'node:fs' -import { appendFile, readFile, stat } from 'node:fs/promises' +import { createReadStream, existsSync } from 'node:fs' +import { appendFile, stat } from 'node:fs/promises' import * as path from 'node:path' +import { StringDecoder } from 'node:string_decoder' import { checkIgnoredPaths } from './check-ignored-paths' import type { GitRuntimeOptions } from './git-runtime-options' @@ -9,6 +10,78 @@ import type { GitRuntimeOptions } from './git-runtime-options' // to ignore these by name (matching the well-known offenders) the way a mature // SCM does, rather than asking the user to hand-edit .gitignore. const KNOWN_HUGE_FOLDER_NAMES = ['node_modules', '.next', 'dist', 'build', 'target', 'vendor'] +const GITIGNORE_SCAN_CHUNK_BYTES = 64 * 1024 + +type GitignoreScanResult = { + alreadyListed: boolean + hasContent: boolean + endsWithNewline: boolean +} + +async function scanGitignoreForFolder( + gitignorePath: string, + folderName: string +): Promise { + const targets = [folderName, `${folderName}/`] + const decoder = new StringDecoder('utf8') + const input = createReadStream(gitignorePath, { highWaterMark: GITIGNORE_SCAN_CHUNK_BYTES }) + let phase: 'leading' | 'candidate' | 'trailing' | 'rejected' = 'leading' + let candidate = '' + let alreadyListed = false + let hasContent = false + let endsWithNewline = false + + const finishLine = (): void => { + if (phase === 'trailing' || (phase === 'candidate' && targets.includes(candidate))) { + alreadyListed = true + } + phase = 'leading' + candidate = '' + } + const acceptText = (text: string): void => { + for (const character of text) { + if (character === '\n') { + finishLine() + continue + } + const whitespace = character.trim().length === 0 + if (phase === 'leading') { + if (whitespace) { + continue + } + candidate = character + phase = targets.some((target) => target.startsWith(candidate)) ? 'candidate' : 'rejected' + continue + } + if (phase === 'candidate') { + if (whitespace) { + phase = targets.includes(candidate) ? 'trailing' : 'rejected' + continue + } + candidate += character + if (!targets.some((target) => target.startsWith(candidate))) { + phase = 'rejected' + } + continue + } + if (phase === 'trailing' && !whitespace) { + phase = 'rejected' + } + } + } + + for await (const chunk of input) { + const buffer = chunk as Buffer + hasContent ||= buffer.length > 0 + if (buffer.length > 0) { + endsWithNewline = buffer.at(-1) === 0x0a + } + acceptText(decoder.write(buffer)) + } + acceptText(decoder.end()) + finishLine() + return { alreadyListed, hasContent, endsWithNewline } +} /** * Return the relative names of known-huge folders that exist in the worktree and @@ -58,21 +131,21 @@ export async function appendFolderToGitignore( } const gitignorePath = path.join(worktreePath, '.gitignore') const line = `${safeFolderName}/` - let existingContent = '' + let scan: GitignoreScanResult = { + alreadyListed: false, + hasContent: false, + endsWithNewline: false + } try { - existingContent = await readFile(gitignorePath, 'utf-8') + scan = await scanGitignoreForFolder(gitignorePath, safeFolderName) } catch { // .gitignore doesn't exist yet — we'll create it below } - const alreadyListed = existingContent - .split(/\r?\n/) - .map((l) => l.trim()) - .some((l) => l === safeFolderName || l === line) - if (alreadyListed) { + if (scan.alreadyListed) { return false } // Why: keep a clean trailing newline whether or not the file ended with one. - const needsLeadingNewline = existingContent.length > 0 && !existingContent.endsWith('\n') + const needsLeadingNewline = scan.hasContent && !scan.endsWithNewline await appendFile(gitignorePath, `${needsLeadingNewline ? '\n' : ''}${line}\n`, 'utf-8') return true } diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index df5b2865132..240b58d44d5 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,5 +1,5 @@ /* oxlint-disable max-lines */ -import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { existsSync, realpathSync, statSync } from 'node:fs' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import { gitExecFileSync, gitExecFileAsync } from './runner' import type { BaseRefSearchResult } from '../../shared/types' @@ -10,6 +10,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url' import { getLocalGitCapabilityCache } from './git-capability-state' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' type LocalGitExecOptions = { wslDistro?: string @@ -21,6 +22,7 @@ type LocalDefaultBaseRefGitOptions = { } const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 +export const MAX_GIT_MARKER_FILE_BYTES = 64 * 1024 type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } @@ -155,6 +157,52 @@ export function getGitRepoRoot(path: string): string { return path } +function canonicalizeGitDirPath(path: string): string { + return resolveRealPathSync(path) ?? path +} + +/** + * Main-checkout path when `path` is a *linked* worktree, else null (main worktree, bare repo, + * non-repo, or any git failure). A linked worktree's `--git-dir` is `/worktrees/` + * while the main worktree's equals `--git-common-dir`; comparing the two from one invocation is + * git's own canonical test and avoids symlink-canonicalization mismatches. Baseline-safe: both + * flags long predate Git 2.25, and a relative answer resolves against `path` as old Git reports it. + */ +export function getLinkedWorktreeMainRepoRoot(path: string): string | null { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return null + } + if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { + return null + } + const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { + cwd: path + }) + .split('\n') + .map((line) => line.trim()) + if (!gitDir || !commonDir) { + return null + } + // Why realpath both: git answers one flag absolutely (already symlink-resolved) and the other + // relative to cwd, so a repo under a symlinked root (macOS /var -> /private/var) compares + // unequal on raw strings and a main checkout gets misread as a linked worktree. + const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) + if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { + return null + } + // A bare/separate git dir has no adjacent working checkout to point at. + if (basename(absoluteCommonDir) !== '.git') { + return null + } + // Re-resolve through getGitRepoRoot so the returned path matches the canonical form + // add-project stores for the main checkout (symlinks resolved the way git reports them). + return getGitRepoRoot(dirname(absoluteCommonDir)) + } catch { + return null + } +} + export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { const inputWsl = parseWslUncPath(inputPath) if (inputWsl && rootPath.startsWith('/')) { @@ -276,7 +324,10 @@ function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { if (marker.isFile()) { let gitDir: string | null try { - gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) + gitDir = parseGitdirFile( + worktreePath, + readNodeFileSyncWithinLimit(dotGit, MAX_GIT_MARKER_FILE_BYTES).buffer.toString('utf8') + ) } catch { return { status: 'invalid' } } @@ -331,7 +382,10 @@ function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { } const commonDir = resolveGitMetadataPath( gitDir, - readFileSync(join(gitDir, 'commondir'), 'utf8') + readNodeFileSyncWithinLimit( + join(gitDir, 'commondir'), + MAX_GIT_MARKER_FILE_BYTES + ).buffer.toString('utf8') ) return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) } catch { @@ -345,7 +399,10 @@ function hasValidBareRepoMarkerSync(path: string): boolean { function gitConfigDeclaresNonBare(gitDir: string): boolean { try { - const config = readFileSync(join(gitDir, 'config'), 'utf8') + const config = readNodeFileSyncWithinLimit( + join(gitDir, 'config'), + MAX_GIT_MARKER_FILE_BYTES + ).buffer.toString('utf8') let inCoreSection = false for (const line of config.split(/\r?\n/)) { const section = line.match(/^\s*\[([^\]]+)\]/) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 35a422e1b39..4cd81cda230 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -20,7 +20,7 @@ vi.mock('../wsl', async (importOriginal) => ({ getDefaultWslDistro: getDefaultWslDistroMock })) -import { ghExecFileAsync, glabExecFileAsync, setDefaultWslDistroOverride } from './runner' +import { ghExecFileAsync, glabExecFileAsync } from './runner' import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' const PRIMARY_RATE_LIMIT_STDERR = @@ -48,7 +48,6 @@ describe('ghExecFileAsync WSL fallback', () => { spawnMock.mockReset() getDefaultWslDistroMock.mockReset() getDefaultWslDistroMock.mockReturnValue(null) - setDefaultWslDistroOverride(null) _resetGhRateLimitBreaker() Object.defineProperty(process, 'platform', { configurable: true, @@ -625,66 +624,4 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenCalledTimes(2) }) - - it('resolves fallback to the overridden distro if configured, and falls back to default WSL distro otherwise', async () => { - // 1) Test with override configured (should use 'Debian' override) - setDefaultWslDistroOverride('Debian') - getDefaultWslDistroMock.mockReturnValue('Ubuntu') - - execFileMock - .mockImplementationOnce((_binary, _args, _options, callback) => { - callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })) - }) - .mockImplementationOnce((binary, args, _options, callback) => { - if (binary === 'wsl.exe' && args.includes('Debian')) { - callback(null, { stdout: 'Logged in to github.com as override', stderr: '' }) - return - } - callback(new Error('Wrong distro fallback')) - }) - - await expect(ghExecFileAsync(['auth', 'status'])).resolves.toEqual({ - stdout: 'Logged in to github.com as override', - stderr: '' - }) - - expect(execFileMock).toHaveBeenCalledTimes(2) - expect(execFileMock).toHaveBeenNthCalledWith( - 2, - 'wsl.exe', - ['-d', 'Debian', '--', 'bash', '-c', "'gh' 'auth' 'status'"], - expect.any(Object), - expect.any(Function) - ) - - // 2) Test without override (should use default 'Ubuntu') - execFileMock.mockClear() - setDefaultWslDistroOverride(null) - - execFileMock - .mockImplementationOnce((_binary, _args, _options, callback) => { - callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })) - }) - .mockImplementationOnce((binary, args, _options, callback) => { - if (binary === 'wsl.exe' && args.includes('Ubuntu')) { - callback(null, { stdout: 'Logged in to github.com as default', stderr: '' }) - return - } - callback(new Error('Wrong distro fallback')) - }) - - await expect(ghExecFileAsync(['auth', 'status'])).resolves.toEqual({ - stdout: 'Logged in to github.com as default', - stderr: '' - }) - - expect(execFileMock).toHaveBeenCalledTimes(2) - expect(execFileMock).toHaveBeenNthCalledWith( - 2, - 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "'gh' 'auth' 'status'"], - expect.any(Object), - expect.any(Function) - ) - }) }) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 716cf20524f..b9cfb4aa646 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -33,6 +33,7 @@ import { appendGitConfigEnv, gitCredentialPromptGuardEnv } from '../../shared/git-credential-prompt-env' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' import { buildWslLoginShellCommand, @@ -164,15 +165,8 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand { } } -let defaultWslDistroOverride: string | null = null - -// Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails. -export function setDefaultWslDistroOverride(distro: string | null): void { - defaultWslDistroOverride = distro -} - function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null { - const distro = defaultWslDistroOverride ?? getDefaultWslDistro() + const distro = getDefaultWslDistro() return distro ? resolveCommand(command, args, undefined, distro) : null } @@ -278,6 +272,7 @@ type CommandExecOptions = { timeout?: number env?: NodeJS.ProcessEnv signal?: AbortSignal + wslDistro?: string } function isMissingCommandError(error: unknown): boolean { @@ -504,6 +499,7 @@ async function spawnCommandCapture( args: string[], options: CommandExecOptions ): Promise<{ stdout: string; stderr: string }> { + const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) return new Promise((resolve, reject) => { if (options.signal?.aborted) { @@ -511,10 +507,8 @@ async function spawnCommandCapture( return } let settled = false - let stdout = '' - let stderr = '' - let stdoutBytes = 0 - let stderrBytes = 0 + const stdout = new GrowingByteBuffer() + const stderr = new GrowingByteBuffer() const spawnStartedAt = performance.now() const child = spawn(spawnCmd, spawnArgs, { cwd: options.cwd, @@ -545,11 +539,15 @@ async function spawnCommandCapture( } settled = true cleanupListeners() + const stdoutText = stdout.toString(options.encoding ?? 'utf-8') + const stderrText = stderr.toString(options.encoding ?? 'utf-8') + stdout.clear() + stderr.clear() if (error) { - reject(Object.assign(error, { stdout, stderr })) + reject(Object.assign(error, { stdout: stdoutText, stderr: stderrText })) return } - resolve({ stdout, stderr }) + resolve({ stdout: stdoutText, stderr: stderrText }) } timer = options.timeout ? setTimeout(() => { @@ -559,22 +557,20 @@ async function spawnCommandCapture( : null options.signal?.addEventListener('abort', onAbort, { once: true }) function onStdoutData(chunk: Buffer): void { - stdoutBytes += chunk.byteLength - if (options.maxBuffer && stdoutBytes > options.maxBuffer) { + if (stdout.byteLength + chunk.byteLength > maxBuffer) { void killSpawnedCommandTree(child) finish(new Error(`${command} stdout exceeded maxBuffer.`)) return } - stdout += chunk.toString(options.encoding ?? 'utf-8') + stdout.append(chunk) } function onStderrData(chunk: Buffer): void { - stderrBytes += chunk.byteLength - if (options.maxBuffer && stderrBytes > options.maxBuffer) { + if (stderr.byteLength + chunk.byteLength > maxBuffer) { void killSpawnedCommandTree(child) finish(new Error(`${command} stderr exceeded maxBuffer.`)) return } - stderr += chunk.toString(options.encoding ?? 'utf-8') + stderr.append(chunk) } function onError(error: Error): void { finish(error) @@ -879,23 +875,24 @@ export async function commandExecFileAsync( args: string[], options: CommandExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { - const resolved = resolveCommand(command, args, options.cwd) + const { wslDistro, ...execOptions } = options + const resolved = resolveCommand(command, args, options.cwd, wslDistro) const binary = resolved.wsl === null ? resolveWindowsCommand(resolved.binary, options.env) : resolved.binary if (isWindowsBatchScript(binary)) { return spawnCommandCapture(binary, resolved.args, { - ...options, + ...execOptions, cwd: resolved.cwd }) } try { const { stdout, stderr } = await execFileCapture(binary, resolved.args, { cwd: resolved.cwd, - encoding: options.encoding ?? 'utf-8', - maxBuffer: options.maxBuffer, - timeout: options.timeout, - env: options.env, - signal: options.signal + encoding: execOptions.encoding ?? 'utf-8', + maxBuffer: execOptions.maxBuffer, + timeout: execOptions.timeout, + env: execOptions.env, + signal: execOptions.signal }) return { stdout: stdout as string, stderr: stderr as string } } catch (error) { @@ -904,7 +901,7 @@ export async function commandExecFileAsync( resolveWindowsCommand(`${resolved.binary}.cmd`, options.env), resolved.args, { - ...options, + ...execOptions, cwd: resolved.cwd } ) @@ -979,11 +976,9 @@ export async function gitStreamStdout( let settled = false let stoppedEarly = false let stdoutBytes = 0 - let stderr = '' - let stderrBytes = 0 + const stderr = new GrowingByteBuffer() // Why: decode statefully so a multibyte UTF-8 char split across chunks isn't corrupted into replacement chars. const stdoutDecoder = new StringDecoder('utf8') - const stderrDecoder = new StringDecoder('utf8') const cleanup = (): void => { child.stdout?.off('data', onStdoutData) @@ -993,7 +988,6 @@ export async function gitStreamStdout( options.signal?.removeEventListener('abort', onAbort) // Flush any bytes the decoders were holding for an incomplete sequence. stdoutDecoder.end() - stderrDecoder.end() } const finish = (error: Error | null): void => { if (settled) { @@ -1001,8 +995,10 @@ export async function gitStreamStdout( } settled = true cleanup() + const stderrText = stderr.toString() + stderr.clear() if (error) { - reject(Object.assign(error, { stderr })) + reject(Object.assign(error, { stderr: stderrText })) return } resolve({ stoppedEarly }) @@ -1036,13 +1032,12 @@ export async function gitStreamStdout( } } function onStderrData(chunk: Buffer): void { - stderrBytes += chunk.byteLength - if (stderrBytes > maxBuffer) { + if (stderr.byteLength + chunk.byteLength > maxBuffer) { void killSpawnedCommandTree(child) finish(new Error('git stderr exceeded maxBuffer.')) return } - stderr += stderrDecoder.write(chunk) + stderr.append(chunk) } function onError(error: Error): void { finish(error) @@ -1052,7 +1047,7 @@ export async function gitStreamStdout( finish(null) return } - finish(new Error(`git exited with ${code}: ${stderr}`)) + finish(new Error(`git exited with ${code}: ${stderr.toString()}`)) } function onAbort(): void { if (!child.pid) { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 34c5d750476..06385194e2b 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines */ import { existsSync } from 'node:fs' -import { readFile, stat } from 'node:fs/promises' import * as path from 'node:path' import type { GitBranchChangeEntry, @@ -31,6 +30,8 @@ import { type GitLineStats } from '../../shared/git-uncommitted-line-stats' import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' +import { iterateNulDelimitedFields } from '../../shared/nul-delimited-fields' +import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' import { gitExecFileAsync, gitExecFileAsyncBuffer, @@ -57,12 +58,18 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../../shared/git-status-line-stats-cache' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 +const MAX_GIT_POINTER_FILE_BYTES = 64 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES const BULK_CHUNK_SIZE = 100 const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000 const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512 +export const MAX_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES = 64 * 1024 type EffectiveUpstreamStatusCacheEntry = { expiresAt: number @@ -71,9 +78,19 @@ type EffectiveUpstreamStatusCacheEntry = { const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 -type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } +export const MAX_SUBMODULE_PATHS_PER_REPO = 10_000 +export const MAX_SUBMODULE_PATH_CODE_UNITS = 64 * 1024 +export const MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS = 4 * 1024 * 1024 +export const MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS = 16 * 1024 * 1024 +const MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES = 64 * 1024 +type SubmodulePathsCacheEntry = { + paths: string[] + expiresAt: number + retainedCodeUnits: number +} const submodulePathsCache = new Map() let submodulePathsCacheGeneration = 0 +let submodulePathsCacheCodeUnits = 0 // Why: cache the upstream name to skip its 4-5-spawn resolution chain each poll; revalidate via one rev-list (issue #7576). const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000 @@ -117,6 +134,7 @@ export function clearSubmodulePathsCacheForTests(): void { function clearSubmodulePathsCache(): void { submodulePathsCache.clear() + submodulePathsCacheCodeUnits = 0 // Why: bump the generation so a pre-mutation read can't repopulate the invalidated cache. submodulePathsCacheGeneration += 1 } @@ -125,6 +143,10 @@ export function getSubmodulePathsCacheCountForTests(): number { return submodulePathsCache.size } +export function getSubmodulePathsCacheCodeUnitsForTests(): number { + return submodulePathsCacheCodeUnits +} + function gitRuntimeOptionsKey(options: GitRuntimeOptions): readonly unknown[] { return [options.wslDistro ?? null] } @@ -137,18 +159,30 @@ function getSubmodulePathsCacheKey(worktreePath: string, options: GitRuntimeOpti function pruneExpiredSubmodulePathsCache(now: number): void { for (const [cacheKey, entry] of submodulePathsCache) { if (entry.expiresAt <= now) { - submodulePathsCache.delete(cacheKey) + deleteSubmodulePathsCacheEntry(cacheKey) } } } +function deleteSubmodulePathsCacheEntry(cacheKey: string): void { + const entry = submodulePathsCache.get(cacheKey) + if (!entry) { + return + } + submodulePathsCache.delete(cacheKey) + submodulePathsCacheCodeUnits -= entry.retainedCodeUnits +} + function trimSubmodulePathsCache(): void { - while (submodulePathsCache.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { + while ( + submodulePathsCache.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES || + submodulePathsCacheCodeUnits > MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS + ) { const oldestKey = submodulePathsCache.keys().next().value if (oldestKey === undefined) { break } - submodulePathsCache.delete(oldestKey) + deleteSubmodulePathsCacheEntry(oldestKey) } } @@ -158,7 +192,7 @@ function getCachedSubmodulePaths(cacheKey: string, now: number): string[] | null return null } if (cached.expiresAt <= now) { - submodulePathsCache.delete(cacheKey) + deleteSubmodulePathsCacheEntry(cacheKey) return null } submodulePathsCache.delete(cacheKey) @@ -167,8 +201,23 @@ function getCachedSubmodulePaths(cacheKey: string, now: number): string[] | null } function rememberSubmodulePaths(cacheKey: string, paths: string[], now: number): void { - submodulePathsCache.delete(cacheKey) - submodulePathsCache.set(cacheKey, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) + if (Buffer.byteLength(cacheKey, 'utf8') > MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES) { + deleteSubmodulePathsCacheEntry(cacheKey) + return + } + const retainedCodeUnits = + cacheKey.length + paths.reduce((total, submodulePath) => total + submodulePath.length, 0) + if (retainedCodeUnits > MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS) { + deleteSubmodulePathsCacheEntry(cacheKey) + return + } + deleteSubmodulePathsCacheEntry(cacheKey) + submodulePathsCache.set(cacheKey, { + paths, + expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS, + retainedCodeUnits + }) + submodulePathsCacheCodeUnits += retainedCodeUnits trimSubmodulePathsCache() } @@ -585,6 +634,10 @@ function getEffectiveUpstreamStatusCacheKey( return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0') } +function canRetainEffectiveUpstreamCacheKey(cacheKey: string): boolean { + return Buffer.byteLength(cacheKey, 'utf8') <= MAX_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES +} + export function clearEffectiveUpstreamNegativeStatusCache(identity: { worktreePath: string branchName: string @@ -601,6 +654,9 @@ export function clearEffectiveUpstreamNegativeStatusCache(identity: { effectiveUpstreamStatusCache.delete(cacheKey) effectiveUpstreamStatusInFlight.delete(cacheKey) resolvedUpstreamNameCache.delete(cacheKey) + if (!canRetainEffectiveUpstreamCacheKey(cacheKey)) { + return + } effectiveUpstreamStatusWriteGeneration.set( cacheKey, (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 @@ -666,6 +722,11 @@ function rememberEffectiveUpstreamStatus( probedSameNameOriginRef: boolean, writeGeneration: number ): void { + if (!canRetainEffectiveUpstreamCacheKey(cacheKey)) { + effectiveUpstreamStatusCache.delete(cacheKey) + effectiveUpstreamStatusWriteGeneration.delete(cacheKey) + return + } // Why: hasConfiguredPushTarget gates a write action; re-probe each poll rather than cache a stale positive. if (status.hasUpstream || status.hasConfiguredPushTarget) { effectiveUpstreamStatusCache.delete(cacheKey) @@ -702,7 +763,8 @@ async function readOrProbeEffectiveUpstreamStatus( options: GitRuntimeOptions = {}, bypassCache = false ): Promise { - if (!bypassCache) { + const cacheable = !bypassCache && canRetainEffectiveUpstreamCacheKey(cacheKey) + if (cacheable) { const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) if (cached) { return cached @@ -721,7 +783,7 @@ async function readOrProbeEffectiveUpstreamStatus( worktreePath, branchName, options, - bypassCache + !cacheable ).then((result) => { rememberEffectiveUpstreamStatus( cacheKey, @@ -732,7 +794,7 @@ async function readOrProbeEffectiveUpstreamStatus( ) return result.status }) - if (!bypassCache) { + if (cacheable) { effectiveUpstreamStatusInFlight.set(cacheKey, probe) } try { @@ -773,7 +835,11 @@ async function probeOrRevalidateEffectiveUpstreamStatus( } } const result = await probeEffectiveUpstreamStatus(worktreePath, branchName, options) - if (result.status.hasUpstream && result.status.upstreamName) { + if ( + canRetainEffectiveUpstreamCacheKey(cacheKey) && + result.status.hasUpstream && + result.status.upstreamName + ) { resolvedUpstreamNameCache.set(cacheKey, { upstreamName: result.status.upstreamName, expiresAt: Date.now() + RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS @@ -973,7 +1039,9 @@ export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') try { - const dotGitContents = await readFile(dotGitPath, 'utf-8') + const dotGitContents = ( + await readNodeFileWithinLimit(dotGitPath, MAX_GIT_POINTER_FILE_BYTES) + ).buffer.toString('utf-8') const match = dotGitContents.match(/^gitdir:\s*(.+)\s*$/m) if (match) { return path.resolve(worktreePath, match[1]) @@ -989,6 +1057,37 @@ export async function resolveGitDir(worktreePath: string): Promise { * List configured submodule paths (relative, forward-slash) for a worktree, cached * briefly. Read from `.gitmodules` to avoid an index-wide `ls-files` scan. */ +function parseSubmodulePaths(stdout: string): string[] | null { + const paths: string[] = [] + let retainedCodeUnits = 0 + for (const line of iterateProcessOutputLines(stdout)) { + if (line.length > MAX_SUBMODULE_PATH_CODE_UNITS + 4_096) { + return null + } + const spaceIndex = line.indexOf(' ') + const submodulePath = + spaceIndex === -1 + ? '' + : line + .slice(spaceIndex + 1) + .trim() + .replace(/\/+$/, '') + if (!submodulePath) { + continue + } + if ( + paths.length >= MAX_SUBMODULE_PATHS_PER_REPO || + submodulePath.length > MAX_SUBMODULE_PATH_CODE_UNITS || + submodulePath.length > MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS - retainedCodeUnits + ) { + return null + } + paths.push(submodulePath) + retainedCodeUnits += submodulePath.length + } + return paths +} + export async function listSubmodulePaths( worktreePath: string, options: GitRuntimeOptions = {} @@ -1008,18 +1107,7 @@ export async function listSubmodulePaths( ['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } ) - paths = stdout - .split(/\r?\n/) - .map((line) => { - const spaceIndex = line.indexOf(' ') - return spaceIndex === -1 - ? '' - : line - .slice(spaceIndex + 1) - .trim() - .replace(/\/+$/, '') - }) - .filter((value) => value.length > 0) + paths = parseSubmodulePaths(stdout) ?? [] } catch { // No .gitmodules (or git config failure) — treat as a repo without submodules. paths = [] @@ -1770,31 +1858,23 @@ async function readGitBlobAtOidPath( } async function readWorkingTreeFile(filePath: string): Promise { - let fileStat try { - fileStat = await stat(filePath) + const { buffer, stats: fileStat } = await readNodeFileWithinLimit(filePath, MAX_GIT_SHOW_BYTES) + if (!fileStat.isFile()) { + return { content: '', isBinary: false, exists: false } + } + return bufferToBlob(buffer, filePath) } catch (error) { - // Why: only ENOENT is a real deletion; other stat errors are read failures, not absence. + if (error instanceof NodeFileReadTooLargeError) { + return { content: '', isBinary: true, exists: true } + } + // Why: only ENOENT is a real deletion; other read errors are failures, not absence. return { content: '', isBinary: false, exists: (error as NodeJS.ErrnoException)?.code !== 'ENOENT' } } - if (!fileStat.isFile()) { - return { content: '', isBinary: false, exists: false } - } - if (fileStat.size > MAX_GIT_SHOW_BYTES) { - // Why: mirror git's maxBuffer cap for working-tree reads so readFile can't pull in huge assets. - return { content: '', isBinary: true, exists: true } - } - try { - const buffer = await readFile(filePath) - return bufferToBlob(buffer, filePath) - } catch { - // Why: the file exists but could not be read — a read failure, not a deletion. - return { content: '', isBinary: false, exists: true } - } } function bufferToBlob(buffer: Buffer, filePath?: string): GitBlobReadResult { @@ -2069,7 +2149,7 @@ async function listTrackedPathSpecs( } ) // Why: a tracked directory can hold enough paths to exceed the JS argument limit. - for (const trackedPath of stdout.split('\0')) { + for (const trackedPath of iterateNulDelimitedFields(stdout)) { if (trackedPath) { trackedPaths.push(trackedPath) } diff --git a/src/main/gitea/client.test.ts b/src/main/gitea/client.test.ts index eada3d6cd95..112c03ab6c9 100644 --- a/src/main/gitea/client.test.ts +++ b/src/main/gitea/client.test.ts @@ -17,7 +17,11 @@ import { } from './client' import { _resetGiteaRepoRefCache } from './repository-ref' import { + GITEA_SCAN_CACHE_ENTRY_MAX_BYTES, + GITEA_SCAN_MAX_IN_FLIGHT, + GITEA_SCAN_REPO_KEY_MAX_BYTES, _getGiteaPullRequestScanCacheSize, + _getGiteaPullRequestScanState, _resetGiteaPullRequestScanCache, scanGiteaPullRequests } from './pull-request-scan-cache' @@ -299,6 +303,62 @@ describe('Gitea client', () => { } }) + it('skips retained state for oversized scan keys and payloads', async () => { + const exactKey = 'k'.repeat(GITEA_SCAN_REPO_KEY_MAX_BYTES) + const oversizedKey = `${exactKey}x` + await scanGiteaPullRequests(exactKey, async () => [], 50, 5) + await scanGiteaPullRequests(oversizedKey, async () => [], 50, 5) + const oversizedListing = [giteaPr(1, 'x'.repeat(GITEA_SCAN_CACHE_ENTRY_MAX_BYTES))] + await expect( + scanGiteaPullRequests('oversized-payload', async () => oversizedListing, 50, 5) + ).resolves.toEqual(oversizedListing) + + expect(_getGiteaPullRequestScanCacheSize()).toBe(1) + }) + + it('caps distinct in-flight scans and recovers tracked capacity', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const scans = Array.from({ length: GITEA_SCAN_MAX_IN_FLIGHT }, (_, index) => + scanGiteaPullRequests( + `in-flight-${index}`, + async () => { + await gate + return [] + }, + 50, + 5 + ) + ) + let overflowCalls = 0 + await scanGiteaPullRequests( + 'overflow', + async () => { + overflowCalls += 1 + return [] + }, + 50, + 5 + ) + await scanGiteaPullRequests( + 'overflow', + async () => { + overflowCalls += 1 + return [] + }, + 50, + 5 + ) + + expect(_getGiteaPullRequestScanState().inFlight).toBe(GITEA_SCAN_MAX_IN_FLIGHT) + expect(overflowCalls).toBe(2) + release() + await Promise.all(scans) + expect(_getGiteaPullRequestScanState().inFlight).toBe(0) + }) + it('does not let an in-flight scan re-cache results from before an invalidation', async () => { let releaseFirstScan!: () => void const firstScanGate = new Promise((resolve) => { diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index ba9e7c3ed8f..ca805222106 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -14,6 +14,7 @@ import { type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' const REQUEST_TIMEOUT_MS = 5000 // Why: self-hosted Forgejo can take ~5s to serve one /pulls page (it loads @@ -102,7 +103,7 @@ async function requestJsonAtBase( } return null } - return (await response.json()) as T + return await readFetchResponseJsonWithinLimit(response) } catch (error) { if (throwOnFailure) { throw error diff --git a/src/main/gitea/pull-request-scan-cache.ts b/src/main/gitea/pull-request-scan-cache.ts index 8864f087d6d..5a70b232fed 100644 --- a/src/main/gitea/pull-request-scan-cache.ts +++ b/src/main/gitea/pull-request-scan-cache.ts @@ -1,4 +1,5 @@ import type { RawGiteaPullRequest } from './pull-request-mappers' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' export type GiteaPullRequestPageFetcher = (page: number) => Promise @@ -16,7 +17,11 @@ const SCAN_TTL_MS = 30_000 const FAILED_SCAN_RETRY_MS = 3_000 // Why: each entry can retain hundreds of full PR payloads, so TTL alone is not // enough protection when many repositories are opened during one app session. -const MAX_SCAN_CACHE_ENTRIES = 32 +export const GITEA_SCAN_CACHE_MAX_ENTRIES = 32 +export const GITEA_SCAN_MAX_IN_FLIGHT = 32 +export const GITEA_SCAN_REPO_KEY_MAX_BYTES = 4 * 1024 +export const GITEA_SCAN_CACHE_ENTRY_MAX_PULL_REQUESTS = 250 +export const GITEA_SCAN_CACHE_ENTRY_MAX_BYTES = 512 * 1024 const scanCache = new Map() const inFlightScans = new Map>() @@ -41,6 +46,9 @@ function rememberScanCacheEntry( ttlMs: number ): void { removeScanCacheEntry(repoKey) + if (!isRetainablePullRequestListing(pullRequests)) { + return + } let entry!: GiteaPullRequestScanEntry const expirationTimer = setTimeout(() => removeScanCacheEntry(repoKey, entry), ttlMs) expirationTimer.unref() @@ -50,7 +58,7 @@ function rememberScanCacheEntry( pullRequests } scanCache.set(repoKey, entry) - while (scanCache.size > MAX_SCAN_CACHE_ENTRIES) { + while (scanCache.size > GITEA_SCAN_CACHE_MAX_ENTRIES) { const oldestKey = scanCache.keys().next().value if (oldestKey === undefined) { break @@ -59,6 +67,65 @@ function rememberScanCacheEntry( } } +function isRetainableRepoKey(repoKey: string): boolean { + return !measureUtf8ByteLength(repoKey, { + stopAfterBytes: GITEA_SCAN_REPO_KEY_MAX_BYTES + }).exceededLimit +} + +function isRetainablePullRequestListing(pullRequests: RawGiteaPullRequest[]): boolean { + if (pullRequests.length > GITEA_SCAN_CACHE_ENTRY_MAX_PULL_REQUESTS) { + return false + } + let remainingBytes = GITEA_SCAN_CACHE_ENTRY_MAX_BYTES - pullRequests.length * 128 + if (remainingBytes < 0) { + return false + } + for (const pullRequest of pullRequests) { + const values = [ + pullRequest.title, + pullRequest.state, + pullRequest.html_url, + pullRequest.updated_at, + pullRequest.head?.ref, + pullRequest.head?.label, + pullRequest.head?.sha + ] + for (const value of values) { + if (typeof value !== 'string') { + continue + } + const measured = measureUtf8ByteLength(value, { stopAfterBytes: remainingBytes }) + if (measured.exceededLimit) { + return false + } + remainingBytes -= measured.byteLength + } + } + return true +} + +async function collectPullRequests( + fetchPage: GiteaPullRequestPageFetcher, + pageLimit: number, + maxPages: number +): Promise<{ completed: boolean; pullRequests: RawGiteaPullRequest[] }> { + const pullRequests: RawGiteaPullRequest[] = [] + let completed = true + for (let page = 1; page <= maxPages; page++) { + const list = await fetchPage(page) + if (!list) { + completed = false + break + } + pullRequests.push(...list) + if (list.length < pageLimit) { + break + } + } + return { completed, pullRequests } +} + function reusableScanCacheEntry(repoKey: string): GiteaPullRequestScanEntry | null { const entry = scanCache.get(repoKey) if (!entry) { @@ -89,6 +156,9 @@ export async function scanGiteaPullRequests( pageLimit: number, maxPages: number ): Promise { + if (!isRetainableRepoKey(repoKey)) { + return (await collectPullRequests(fetchPage, pageLimit, maxPages)).pullRequests + } const cached = reusableScanCacheEntry(repoKey) if (cached) { return cached.pullRequests @@ -97,22 +167,13 @@ export async function scanGiteaPullRequests( if (running) { return running } + if (inFlightScans.size >= GITEA_SCAN_MAX_IN_FLIGHT) { + return (await collectPullRequests(fetchPage, pageLimit, maxPages)).pullRequests + } const generation = scanGenerations.get(repoKey) ?? 0 activeScanCounts.set(repoKey, (activeScanCounts.get(repoKey) ?? 0) + 1) const scan = (async () => { - const pullRequests: RawGiteaPullRequest[] = [] - let completed = true - for (let page = 1; page <= maxPages; page++) { - const list = await fetchPage(page) - if (!list) { - completed = false - break - } - pullRequests.push(...list) - if (list.length < pageLimit) { - break - } - } + const { completed, pullRequests } = await collectPullRequests(fetchPage, pageLimit, maxPages) if ((scanGenerations.get(repoKey) ?? 0) === generation) { rememberScanCacheEntry(repoKey, pullRequests, completed ? SCAN_TTL_MS : FAILED_SCAN_RETRY_MS) } @@ -159,3 +220,17 @@ export function _resetGiteaPullRequestScanCache(): void { export function _getGiteaPullRequestScanCacheSize(): number { return scanCache.size } + +export function _getGiteaPullRequestScanState(): { + cached: number + inFlight: number + active: number + generations: number +} { + return { + cached: scanCache.size, + inFlight: inFlightScans.size, + active: activeScanCounts.size, + generations: scanGenerations.size + } +} diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts index f5a9acc8e5f..29ba407f909 100644 --- a/src/main/gitea/repository-ref.ts +++ b/src/main/gitea/repository-ref.ts @@ -1,5 +1,9 @@ import { gitExecFileAsync } from '../git/runner' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { + buildRepositoryRefCacheKey, + RepositoryRefCache +} from '../source-control/repository-ref-cache' export type GiteaRepoRef = { host: string @@ -20,8 +24,7 @@ const KNOWN_NON_GITEA_HOSTS = new Set([ 'dev.azure.com', 'ssh.dev.azure.com' ]) -const REPO_REF_CACHE_MAX_ENTRIES = 512 -const repoRefCache = new Map() +const repoRefCache = new RepositoryRefCache() /** @internal - exposed for tests only */ export function _resetGiteaRepoRefCache(): void { @@ -33,17 +36,6 @@ export function _getGiteaRepoRefCacheSize(): number { return repoRefCache.size } -function rememberRepoRefCacheEntry(cacheKey: string, value: GiteaRepoRef | null): void { - repoRefCache.set(cacheKey, value) - while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { - const oldestKey = repoRefCache.keys().next().value - if (oldestKey === undefined) { - return - } - repoRefCache.delete(oldestKey) - } -} - function decodeSegment(value: string): string { try { return decodeURIComponent(value) @@ -148,9 +140,10 @@ export async function getGiteaRepoRefForRemote( localGitOptions: LocalGitExecOptions = {} ): Promise { const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` - if (repoRefCache.has(cacheKey)) { - return repoRefCache.get(cacheKey)! + const cacheKey = buildRepositoryRefCacheKey([runtimeKey, repoPath, remoteName]) + const cached = repoRefCache.get(cacheKey) + if (cached.found) { + return cached.value } try { const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null @@ -164,7 +157,11 @@ export async function getGiteaRepoRefForRemote( ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const result = parseGiteaRepoRef(stdout) - rememberRepoRefCacheEntry(cacheKey, result) + repoRefCache.remember( + cacheKey, + result, + result ? [result.host, result.owner, result.repo, result.apiBaseUrl, result.webBaseUrl] : [] + ) return result } catch { if (connectionId) { @@ -172,7 +169,7 @@ export async function getGiteaRepoRefForRemote( // caching them as "not Gitea" would poison the repo for the session. return null } - rememberRepoRefCacheEntry(cacheKey, null) + repoRefCache.remember(cacheKey, null, []) return null } } diff --git a/src/main/github/client-issue-origin-preference.test.ts b/src/main/github/client-issue-origin-preference.test.ts new file mode 100644 index 00000000000..951962a3c25 --- /dev/null +++ b/src/main/github/client-issue-origin-preference.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GhUtils from './gh-utils' + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + resolvePRRepositoryCandidatesMock, + resolveIssueSourceMock, + rateLimitGuardMock, + noteRateLimitSpendMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + resolvePRRepositoryCandidatesMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + rateLimitGuardMock: vi.fn(() => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', async () => { + const actual = await vi.importActual('./gh-utils') + return { + ...actual, + execFileAsync: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, + resolveIssueSource: resolveIssueSourceMock, + acquire: acquireMock, + release: releaseMock, + _resetOwnerRepoCache: vi.fn() + } +}) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: rateLimitGuardMock, + noteRateLimitSpend: noteRateLimitSpendMock, + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: () => true +})) + +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions), + resolveGitHubApiRepositoryCandidates: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions) + } +}) + +import { getWorkItem, _resetOwnerRepoCache } from './client' + +describe('GitHub issue open-by-number origin preference', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + resolvePRRepositoryCandidatesMock.mockReset() + resolveIssueSourceMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) + resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { + const origin = await getOwnerRepoMock(repoPath, connectionId) + const repository = origin ? { host: 'github.com', ...origin } : null + return { candidates: repository ? [repository] : [], headRepo: repository } + }) + _resetOwnerRepoCache() + }) + + it('pins typed issue metadata to explicit origin preference', async () => { + const source = { owner: 'fork', repo: 'orca', host: 'github.com' } + resolveIssueSourceMock.mockResolvedValueOnce({ source, fellBack: false }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'Origin issue', + state: 'open', + labels: [], + url: 'https://github.com/fork/orca/issues/7', + updatedAt: '2026-04-02T00:00:00Z', + author: { login: 'octocat' } + }) + }) + + const item = await getWorkItem('/repo-root', 7, 'issue', null, {}, 'origin') + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'repos/fork/orca/issues/7'], + expect.objectContaining({ cwd: '/repo-root', host: 'github.com' }) + ) + expect(item).toMatchObject({ number: 7, title: 'Origin issue', type: 'issue' }) + }) + + it('does not run a bare issue lookup when explicit origin identity is unresolved', async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false }) + + await expect(getWorkItem('/repo-root', 7, 'issue', null, {}, 'origin')).resolves.toBeNull() + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('skips the issue probe on untyped open when origin identity is unresolved', async () => { + const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } + resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false }) + getOwnerRepoMock.mockResolvedValue(origin) + resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [origin], headRepo: origin }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'Origin PR', + state: 'open', + labels: [], + isDraft: false, + url: 'https://github.com/fork/orca/pull/7', + baseRefName: 'main', + headRefName: 'origin/fix', + updatedAt: '2026-04-02T00:00:00Z', + author: { login: 'octocat' } + }) + }) + + const item = await getWorkItem('/repo-root', 7, undefined, null, {}, 'origin') + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca']) + ) + expect(item).toMatchObject({ number: 7, type: 'pr' }) + }) +}) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index aa16bed0c81..4f8164ff416 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -513,6 +513,54 @@ describe('GitHub issue source split', () => { expect(item?.prRepo).toEqual(upstream) }) + it('pins typed PR metadata to explicit origin when upstream has the same number', async () => { + const upstream = { owner: 'stablyai', repo: 'orca', host: 'github.com' } + const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } + getOwnerRepoMock.mockResolvedValue(origin) + mockUpstreamCandidate(upstream) + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [upstream, origin], + headRepo: origin + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Origin PR', + state: 'open', + url: 'https://github.com/fork/orca/pull/42', + labels: [], + updatedAt: '2026-04-02T00:00:00Z', + author: { login: 'octocat' }, + isDraft: false, + headRefName: 'origin/fix', + baseRefName: 'main' + }) + }) + + const item = await getWorkItem('/repo-root', 42, 'pr', null, {}, 'origin') + + expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca']) + ) + expect( + ghExecFileAsyncMock.mock.calls.some((call) => + (call[0] as string[]).some((arg) => arg.includes('upstream/orca')) + ) + ).toBe(false) + expect(item?.prRepo).toEqual(origin) + }) + + it('does not run a bare PR lookup when explicit origin identity is unresolved', async () => { + getOwnerRepoMock.mockResolvedValue(null) + mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) + + await expect(getWorkItem('/repo-root', 42, 'pr', null, {}, 'origin')).resolves.toBeNull() + + expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('does not run a bare gh lookup for an SSH repo without candidates', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null }) @@ -696,58 +744,6 @@ describe('GitHub issue source split', () => { }) }) - it("preference='auto' + upstream exists → PRs query upstream too", async () => { - // Why: fork-contribution PRs live on the upstream repo — the fork's own - // PR list is almost always empty. 'auto' must resolve PRs upstream-first - // like issues, or the PRs tab renders "No matching GitHub work" on forks. - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - expect.arrayContaining(['--repo', 'stablyai/orca']), - { cwd: '/repo-root' } - ) - expect(result.sources).toEqual({ - issues: { owner: 'stablyai', repo: 'orca' }, - prs: { owner: 'stablyai', repo: 'orca' }, - originCandidate: { owner: 'fork', repo: 'orca' }, - upstreamCandidate: { owner: 'stablyai', repo: 'orca' } - }) - }) - - it('collapses the default count to one query when auto resolves both sides to upstream', async () => { - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '11\n' }) - - const count = await countWorkItems('/repo-root') - - expect(count).toBe(11) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - [ - 'api', - '--cache', - '120s', - `search/issues?q=${encodeURIComponent('repo:stablyai/orca is:open')}&per_page=1`, - '--jq', - '.total_count' - ], - { cwd: '/repo-root' } - ) - }) - it("preference='upstream' + upstream exists → queries upstream", async () => { resolveIssueSourceMock.mockResolvedValueOnce({ source: { owner: 'stablyai', repo: 'orca' }, diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 3f7cdffffd6..af0d3bdf555 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -34,7 +34,7 @@ import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE, sortWorkItemsByNumber } from '../../shared/work-items' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { sliceCheckLogTail } from './check-job-log-tail-slice' @@ -43,8 +43,6 @@ import { safePRRefreshErrorMessage } from './pr-refresh-error-classification' import { getPRConflictSummary } from './conflict-summary' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { splitRemoteBranchName } from '../../shared/git-effective-upstream' import { execFileAsync, @@ -74,9 +72,11 @@ import { } from '../source-control/hosted-review-git-options' import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' import { readLocalGitConfigSignature } from './local-git-config-signature' +import { readHostedReviewTemplate } from '../source-control/pull-request-template' +import { cacheIdentityDigest } from '../cache-identity-digest' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' import { getGitHubApiRepositoryForRemote, - getIssueGitHubApiRepository, getOriginGitHubApiRepository, githubHostExecOptions, githubRepositorySlugArg, @@ -283,16 +283,35 @@ export type PullRequestPushTarget = { maintainerCanModify?: boolean } +// Why: only an explicit `origin` preference is origin-only; `upstream`/`auto`/ +// undefined keep the multi-candidate probe ordered upstream-first, matching +// resolvePrWorkItemSource list semantics. +async function resolvePullRequestLookupCandidates( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (preference === 'origin') { + const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions) + return origin ? [origin] : [] + } + return (await resolveGitHubApiRepositoryCandidates(repoPath, connectionId, localGitOptions)) + .candidates +} + export async function getPullRequestPushTarget( repoPath: string, prNumber: number, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference ): Promise { const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const { candidates } = await resolveGitHubApiRepositoryCandidates( + const candidates = await resolvePullRequestLookupCandidates( repoPath, + preference, connectionId, localGitOptions ) @@ -954,14 +973,19 @@ async function fetchPullRequestWorkItemFromCandidates( repoPath: string, number: number, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference ): Promise { - const { candidates } = await resolveGitHubApiRepositoryCandidates( + const candidates = await resolvePullRequestLookupCandidates( repoPath, + preference, connectionId, localGitOptions ) if (candidates.length === 0) { + if (preference === 'origin') { + return null + } return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions) } for (const candidate of candidates) { @@ -1109,10 +1133,8 @@ async function resolvePrWorkItemSource( getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions), getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions) ]) - // Why: fork-contribution PRs live on the upstream repo (the fork's own PR - // list is almost always empty), so 'auto' resolves upstream-first exactly - // like the issue side. Only an explicit 'origin' pick pins PRs to the fork. - const source = preference === 'origin' ? originCandidate : (upstreamCandidate ?? originCandidate) + const source = + preference === 'upstream' ? (upstreamCandidate ?? originCandidate) : originCandidate return { source, originCandidate, upstreamCandidate } } @@ -1775,41 +1797,6 @@ async function findOpenPRByHeadBase(args: { return { number: list[0].number, url: list[0].url } } -async function readPullRequestTemplate( - repoPath: string, - connectionId?: string | null -): Promise { - const relativeCandidates = [ - '.github/pull_request_template.md', - '.github/PULL_REQUEST_TEMPLATE.md', - 'pull_request_template.md', - 'PULL_REQUEST_TEMPLATE.md', - 'docs/pull_request_template.md', - 'docs/PULL_REQUEST_TEMPLATE.md' - ] - const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - if (connectionId && !remoteProvider) { - return '' - } - for (const relativeCandidate of relativeCandidates) { - try { - if (remoteProvider) { - const result = await remoteProvider.readFile( - joinWorktreeRelativePath(repoPath, relativeCandidate) - ) - if (result.isBinary) { - continue - } - return result.content - } - return await readFile(join(repoPath, relativeCandidate), 'utf8') - } catch { - // Try the next conventional PR template path. - } - } - return '' -} - export async function createGitHubPullRequest( repoPath: string, input: CreateHostedReviewInput, @@ -1864,7 +1851,7 @@ export async function createGitHubPullRequest( try { const body = input.useTemplate && !input.body?.trim() - ? await readPullRequestTemplate(repoPath, connectionId) + ? await readHostedReviewTemplate(repoPath, connectionId, 'github') : (input.body ?? '') await writeFile(bodyPath, body, 'utf8') const createArgs = [ @@ -1953,38 +1940,55 @@ export async function getWorkItem( number: number, type?: 'issue' | 'pr', connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference ): Promise { await acquire() try { + // Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number + // must share that preference so origin/upstream toggles cannot disagree. if (type === 'issue') { - return await fetchIssueWorkItem( + const { source } = await resolveIssueGitHubApiRepositorySource( repoPath, - await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), - number, + preference, connectionId, localGitOptions ) + // Why: explicit origin with no origin identity must not bare-lookup ambient gh + // (same fail-closed rule as origin-pinned PR candidate resolution). + if (!source && preference === 'origin') { + return null + } + return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions) } if (type === 'pr') { return await fetchPullRequestWorkItemFromCandidates( repoPath, number, connectionId, - localGitOptions + localGitOptions, + preference ) } try { - const issue = await fetchIssueWorkItem( + const { source } = await resolveIssueGitHubApiRepositorySource( repoPath, - await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), - number, + preference, connectionId, localGitOptions ) - if (issue) { - return issue + if (source || preference !== 'origin') { + const issue = await fetchIssueWorkItem( + repoPath, + source, + number, + connectionId, + localGitOptions + ) + if (issue) { + return issue + } } } catch (err) { // Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR. @@ -1997,7 +2001,8 @@ export async function getWorkItem( repoPath, number, connectionId, - localGitOptions + localGitOptions, + preference ) } catch { return null @@ -2218,7 +2223,7 @@ async function detectRepositoryMergeMetadata( branchName: string | undefined, ghOptions: GhExecOptions ): Promise { - const cacheKey = `${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` + const cacheKey = cacheIdentityDigest([githubRepoIdentityKey(ownerRepo), branchName ?? '__repo__']) pruneRepositoryMergeMetadataCache() const cached = repositoryMergeMetadataCache.get(cacheKey) if (cached) { @@ -2392,11 +2397,16 @@ type TrackedUpstreamBranch = { const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000 const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512 +export const TRACKED_UPSTREAM_SNAPSHOT_MAX_IN_FLIGHT = 32 +export const TRACKED_UPSTREAM_SNAPSHOT_MAX_BRANCHES = 4096 +export const TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024 +export const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_BYTES = 32 * 1024 * 1024 type TrackedUpstreamSnapshotCacheEntry = { expiresAt: number gitConfigSignature?: string upstreamsByBranchName: Map + retainedBytes: number } type TrackedUpstreamSnapshotProbeResult = { @@ -2404,6 +2414,7 @@ type TrackedUpstreamSnapshotProbeResult = { gitConfigSignature?: string probeFailed: boolean upstreamsByBranchName: Map + retainedBytes: number } const trackedUpstreamSnapshotCache = new Map() @@ -2412,6 +2423,16 @@ const trackedUpstreamSnapshotInFlight = new Map< Promise >() const trackedUpstreamSnapshotGenerations = new Map() +let trackedUpstreamSnapshotCacheBytes = 0 + +function deleteTrackedUpstreamSnapshot(cacheKey: string): void { + const cached = trackedUpstreamSnapshotCache.get(cacheKey) + if (!cached) { + return + } + trackedUpstreamSnapshotCacheBytes -= cached.retainedBytes + trackedUpstreamSnapshotCache.delete(cacheKey) +} function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol { const generation = Symbol() @@ -2429,16 +2450,19 @@ function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol function pruneTrackedUpstreamSnapshotCache(now: number): void { for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) { if (cached.expiresAt <= now) { - trackedUpstreamSnapshotCache.delete(cacheKey) + deleteTrackedUpstreamSnapshot(cacheKey) } } // Why: workspace/runtime churn can create unbounded unique keys within one TTL window, so expiry sweeping alone isn't a memory bound. - while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) { + while ( + trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES || + trackedUpstreamSnapshotCacheBytes > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_BYTES + ) { const oldestKey = trackedUpstreamSnapshotCache.keys().next().value if (oldestKey === undefined) { break } - trackedUpstreamSnapshotCache.delete(oldestKey) + deleteTrackedUpstreamSnapshot(oldestKey) } } @@ -2458,6 +2482,7 @@ export function __resetTrackedUpstreamBranchCacheForTests(): void { trackedUpstreamSnapshotCache.clear() trackedUpstreamSnapshotInFlight.clear() trackedUpstreamSnapshotGenerations.clear() + trackedUpstreamSnapshotCacheBytes = 0 } function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch | null { @@ -2465,7 +2490,10 @@ function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch if (!parsed) { return null } - return parsed + return { + remoteName: parsed.remoteName.replace(/$/u, ''), + branchName: parsed.branchName.replace(/$/u, '') + } } function shouldRetryTrackedUpstreamBranch( @@ -2506,10 +2534,10 @@ async function getTrackedUpstreamBranch( ) { return cached.upstreamsByBranchName.get(branchName) ?? null } - trackedUpstreamSnapshotCache.delete(cacheKey) + deleteTrackedUpstreamSnapshot(cacheKey) } if (cached) { - trackedUpstreamSnapshotCache.delete(cacheKey) + deleteTrackedUpstreamSnapshot(cacheKey) } const inFlight = trackedUpstreamSnapshotInFlight.get(cacheKey) @@ -2526,18 +2554,31 @@ async function getTrackedUpstreamBranch( } } + if (trackedUpstreamSnapshotInFlight.size >= TRACKED_UPSTREAM_SNAPSHOT_MAX_IN_FLIGHT) { + const result = await probeTrackedUpstreamSnapshot( + repoPath, + connectionId, + localGitOptions, + branchName + ) + return result.upstreamsByBranchName.get(branchName) ?? null + } + // Why: PR polling asks about hundreds of branches at once; read all upstreams in one git process per repo/runtime, not one probe per branch. const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey) - const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions) + const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions, branchName) trackedUpstreamSnapshotInFlight.set(cacheKey, probe) try { const result = await probe if (result.cacheable && trackedUpstreamSnapshotGenerations.get(cacheKey) === probeGeneration) { + deleteTrackedUpstreamSnapshot(cacheKey) trackedUpstreamSnapshotCache.set(cacheKey, { ...(result.gitConfigSignature ? { gitConfigSignature: result.gitConfigSignature } : {}), upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName), + retainedBytes: result.retainedBytes, expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS }) + trackedUpstreamSnapshotCacheBytes += result.retainedBytes pruneTrackedUpstreamSnapshotCache(Date.now()) } if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) { @@ -2558,7 +2599,8 @@ async function getTrackedUpstreamBranch( async function probeTrackedUpstreamSnapshot( repoPath: string, connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} + localGitOptions: { wslDistro?: string } = {}, + requestedBranchName?: string ): Promise { const startingGitConfigSignature = await readLocalGitConfigSignature({ repoPath, @@ -2568,8 +2610,10 @@ async function probeTrackedUpstreamSnapshot( const { probeFailed, upstreamsByBranchName } = await probeTrackedUpstreamBranches( repoPath, connectionId, - localGitOptions + localGitOptions, + requestedBranchName ) + const retainedBytes = measureTrackedUpstreamSnapshotBytes(upstreamsByBranchName) const endingGitConfigSignature = await readLocalGitConfigSignature({ repoPath, connectionId: connectionId ?? null, @@ -2585,7 +2629,8 @@ async function probeTrackedUpstreamSnapshot( cacheable: !configSignatureChanged && !probeFailed, probeFailed, ...(gitConfigSignature ? { gitConfigSignature } : {}), - upstreamsByBranchName + upstreamsByBranchName, + retainedBytes } } @@ -2628,13 +2673,14 @@ function getTrackedUpstreamBranchCacheKey( const runtimeKey = connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` - return [runtimeKey, repoPath].join('\0') + return cacheIdentityDigest([runtimeKey, repoPath]) } async function probeTrackedUpstreamBranches( repoPath: string, connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} + localGitOptions: { wslDistro?: string } = {}, + requestedBranchName?: string ): Promise<{ probeFailed: boolean upstreamsByBranchName: Map @@ -2650,29 +2696,106 @@ async function probeTrackedUpstreamBranches( }) return { probeFailed: false, - upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout) + upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout, requestedBranchName) } } catch { return { probeFailed: true, upstreamsByBranchName: new Map() } } } -function parseTrackedUpstreamBranches(stdout: string): Map { +function parseTrackedUpstreamBranches( + stdout: string, + requestedBranchName?: string +): Map { const upstreamsByBranchName = new Map() - for (const line of stdout.split(/\r?\n/)) { + let retainedBytes = 0 + let lineStart = 0 + while (lineStart <= stdout.length) { + const newline = stdout.indexOf('\n', lineStart) + const lineEnd = newline === -1 ? stdout.length : newline + const line = stdout.slice( + lineStart, + lineEnd > lineStart && stdout[lineEnd - 1] === '\r' ? lineEnd - 1 : lineEnd + ) + lineStart = newline === -1 ? stdout.length + 1 : newline + 1 if (!line) { continue } - const [branchName, upstreamRef] = line.split('\0') - const localBranchName = branchName?.replace(/^refs\/heads\//, '') + const separator = line.indexOf('\0') + const branchName = separator === -1 ? line : line.slice(0, separator) + const upstreamRef = separator === -1 ? '' : line.slice(separator + 1) + const localBranchName = branchName.startsWith('refs/heads/') + ? branchName.slice('refs/heads/'.length) + : branchName if (!localBranchName) { continue } - upstreamsByBranchName.set(localBranchName, parseTrackedUpstreamRef(upstreamRef ?? '')) + const parsedUpstream = parseTrackedUpstreamRef(upstreamRef) + const entryBytes = measureTrackedUpstreamEntryBytes(localBranchName, parsedUpstream) + if (entryBytes === null) { + continue + } + const isRequested = localBranchName === requestedBranchName + while ( + isRequested && + upstreamsByBranchName.size > 0 && + (upstreamsByBranchName.size >= TRACKED_UPSTREAM_SNAPSHOT_MAX_BRANCHES || + retainedBytes + entryBytes > TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES) + ) { + const oldest = upstreamsByBranchName.keys().next().value + if (oldest === undefined) { + break + } + retainedBytes -= + measureTrackedUpstreamEntryBytes(oldest, upstreamsByBranchName.get(oldest) ?? null) ?? 0 + upstreamsByBranchName.delete(oldest) + } + if ( + upstreamsByBranchName.size >= TRACKED_UPSTREAM_SNAPSHOT_MAX_BRANCHES || + retainedBytes + entryBytes > TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES + ) { + continue + } + upstreamsByBranchName.set(localBranchName.replace(/$/u, ''), parsedUpstream) + retainedBytes += entryBytes } return upstreamsByBranchName } +export function _parseTrackedUpstreamBranchesForTests( + stdout: string, + requestedBranchName?: string +): Map { + return parseTrackedUpstreamBranches(stdout, requestedBranchName) +} + +function measureTrackedUpstreamEntryBytes( + branchName: string, + upstream: TrackedUpstreamBranch | null +): number | null { + let remainingBytes = TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES - 64 + let bytes = 64 + for (const value of [branchName, upstream?.remoteName ?? '', upstream?.branchName ?? '']) { + const measured = measureUtf8ByteLength(value, { stopAfterBytes: remainingBytes }) + if (measured.exceededLimit) { + return null + } + remainingBytes -= measured.byteLength + bytes += measured.byteLength + } + return bytes <= TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES ? bytes : null +} + +function measureTrackedUpstreamSnapshotBytes( + upstreamsByBranchName: Map +): number { + let bytes = 0 + for (const [branchName, upstream] of upstreamsByBranchName) { + bytes += measureTrackedUpstreamEntryBytes(branchName, upstream) ?? 0 + } + return bytes +} + function parseTrackedUpstreamRef(upstreamRef: string): TrackedUpstreamBranch | null { const remoteRefPrefix = 'refs/remotes/' const normalizedRef = upstreamRef.trim() @@ -3789,7 +3912,9 @@ async function attachFailedJobLogTails( // Why: cap log fetches so failed-job details stay a bounded follow-up, not a burst of hosted log downloads. for (const job of failedJobs) { const jobCacheKey = getCheckJobLogTailCacheKey(job) - const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null + const cacheKey = jobCacheKey + ? cacheIdentityDigest([githubRepoIdentityKey(ownerRepo), jobCacheKey]) + : null if (!cacheKey) { continue } diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index 73ec657977c..fb325adf9e9 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -4,6 +4,7 @@ import { isUnsupportedMergeTreeWriteTreeError } from '../../shared/git-merge-tree-capability' import { gitExecFileAsync } from '../git/runner' +import { iterateNulDelimitedFields } from '../../shared/nul-delimited-fields' import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache @@ -20,11 +21,15 @@ import { storeResolvedBaseTip, storeCachedSummary } from './conflict-summary-cache' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' type LocalGitExecOptions = { wslDistro?: string } +export const PR_CONFLICT_FILES_MAX_ENTRIES = 512 +export const PR_CONFLICT_FILES_MAX_BYTES = 256 * 1024 + export function __resetPRConflictSummaryCachesForTests(): void { clearGitCapabilityStateForTests() __resetPRConflictSummaryDerivationCachesForTests() @@ -301,13 +306,27 @@ async function loadConflictingFilesWithLegacyMergeTree( } } -function parseMergeTreeNameOnlyOutput(stdout: string): string[] { - const entries = stdout.split('\0').filter(Boolean) - if (entries.length === 0) { - return [] +export function parseMergeTreeNameOnlyOutput(stdout: string): string[] { + const files: string[] = [] + let skippedTreeId = false + let retainedBytes = 0 + for (const entry of iterateNulDelimitedFields(stdout)) { + if (!entry) { + continue + } + if (!skippedTreeId) { + skippedTreeId = true + continue + } + const measured = measureUtf8ByteLength(entry, { + stopAfterBytes: PR_CONFLICT_FILES_MAX_BYTES - retainedBytes + }) + if (measured.exceededLimit || files.length >= PR_CONFLICT_FILES_MAX_ENTRIES) { + break + } + files.push(entry.replace(/$/u, '')) + retainedBytes += measured.byteLength } - - const [, ...files] = entries return files } diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index f0277068471..6051c7c1bf3 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -12,7 +12,13 @@ import { parseGitHubRemoteIdentity, type LocalGitExecOptions } from './github-repository-identity' +import { + effectiveGitHubRemoteHost, + gitHubSshConfigHostAlias +} from './github-remote-identity-parsing' +import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution' import { parseWslPath } from '../wsl' +import { cacheIdentityDigest } from '../cache-identity-digest' export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } @@ -22,6 +28,8 @@ export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } // GHES remote is not left to fall through to Gitea (#8312). const HOST_AUTH_TTL_MS = 60_000 const HOST_AUTH_CACHE_MAX_ENTRIES = 512 +const HOST_AUTH_MAX_IN_FLIGHT = 16 +const GITHUB_HOST_MAX_CHARS = 1024 type HostAuthCacheEntry = { authenticatedHost: string | null @@ -82,6 +90,9 @@ type NormalizedGitHubHost = { } function normalizeGitHubHost(host: string): NormalizedGitHubHost | null { + if (host.length > GITHUB_HOST_MAX_CHARS) { + return null + } const match = host .trim() .toLowerCase() @@ -128,8 +139,14 @@ async function resolveAuthenticatedGitHubHost( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const normalizedHost = normalizeGitHubHost(host)?.authority ?? host.trim().toLowerCase() - const cacheKey = `${runtimeCacheKey(repoPath, localGitOptions.wslDistro)}\0${normalizedHost}` + const normalizedHost = normalizeGitHubHost(host)?.authority + if (!normalizedHost) { + return null + } + const cacheKey = cacheIdentityDigest([ + runtimeCacheKey(repoPath, localGitOptions.wslDistro), + normalizedHost + ]) const now = Date.now() pruneHostAuthCache(now) const cached = hostAuthCache.get(cacheKey) @@ -168,6 +185,9 @@ async function resolveAuthenticatedGitHubHost( pruneHostAuthCache(Date.now()) return authenticatedHost })() + if (hostAuthInFlight.size >= HOST_AUTH_MAX_IN_FLIGHT) { + return probe + } hostAuthInFlight.set(cacheKey, probe) try { return await probe @@ -225,11 +245,32 @@ export async function getEnterpriseGitHubRepoSlugForRemote( return null } const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null - if (!identity || identity.host === 'github.com') { + if (!identity) { + return null + } + // Why: GHES routing needs the effective host behind an SSH alias. + let effectiveHost = identity.host + const aliasHost = remoteUrl ? gitHubSshConfigHostAlias(remoteUrl) : null + if (aliasHost) { + const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context) + if (!resolved || !hostname) { + const authenticatedLiteralHost = await resolveAuthenticatedGitHubHost( + identity.host, + repoPath, + connectionId, + localGitOptions + ) + return authenticatedLiteralHost + ? { owner: identity.owner, repo: identity.repo, host: authenticatedLiteralHost } + : undefined + } + effectiveHost = effectiveGitHubRemoteHost(identity.host, hostname) + } + if (effectiveHost === 'github.com') { return null } const authenticatedHost = await resolveAuthenticatedGitHubHost( - identity.host, + effectiveHost, repoPath, connectionId, localGitOptions diff --git a/src/main/github/github-owner-repo-selection.ts b/src/main/github/github-owner-repo-selection.ts new file mode 100644 index 00000000000..63b6d75678e --- /dev/null +++ b/src/main/github/github-owner-repo-selection.ts @@ -0,0 +1,91 @@ +import type { IssueSourcePreference } from '../../shared/types' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' +import { + getOwnerRepoForRemote, + type LocalGitExecOptions, + type OwnerRepo +} from './github-repository-identity' + +export async function getOwnerRepo( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + // Why: on a fork checkout PRs live on the upstream parent, not origin (#7331). + const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) + if (upstream) { + return upstream + } + return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) +} + +export const getIssueOwnerRepo = getOwnerRepo + +export type PRRepositoryCandidates = { + candidates: OwnerRepo[] + headRepo: OwnerRepo | null +} + +export async function resolvePRRepositoryCandidates( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const [upstream, origin] = await Promise.all([ + getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions), + getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) + ]) + const seen = new Set() + const candidates: OwnerRepo[] = [] + + for (const candidate of [upstream, origin]) { + if (!candidate) { + continue + } + const key = githubRepoIdentityKey(candidate) + if (seen.has(key)) { + continue + } + seen.add(key) + candidates.push(candidate) + } + + return { candidates, headRepo: origin } +} + +export type ResolvedIssueSource = { + source: OwnerRepo | null + /** True when explicit upstream is gone and resolver fell back to origin. */ + fellBack: boolean +} + +export async function resolveIssueSource( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (preference === 'upstream') { + const upstream = await getOwnerRepoForRemote( + repoPath, + 'upstream', + connectionId, + localGitOptions + ) + if (upstream) { + return { source: upstream, fellBack: false } + } + const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) + return { source: origin, fellBack: origin !== null } + } + if (preference === 'origin') { + return { + source: await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions), + fellBack: false + } + } + return { + source: await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), + fellBack: false + } +} diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts index c0b8b599520..d2b9f1e629f 100644 --- a/src/main/github/github-remote-identity-parsing.test.ts +++ b/src/main/github/github-remote-identity-parsing.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' -import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity } from './github-remote-identity-parsing' +import { + effectiveGitHubRemoteHost, + gitHubSshConfigHostAlias, + parseGitHubOwnerRepo, + parseGitHubOwnerRepoWithResolvedSshHostname, + parseGitHubRemoteIdentity, + remoteUrlUsesSshTransport +} from './github-remote-identity-parsing' describe('parseGitHubRemoteIdentity', () => { it('parses a plain github.com https remote', () => { @@ -106,4 +113,78 @@ describe('parseGitHubOwnerRepo', () => { repo: 'orca' }) }) + + it('returns null for an SSH Host alias remote without HostName resolution', () => { + expect(parseGitHubOwnerRepo('git@github-work:team/orca.git')).toBeNull() + expect(parseGitHubOwnerRepo('git@github.com-work:team/orca.git')).toBeNull() + expect(parseGitHubOwnerRepo('ssh://git@github-work/team/orca.git')).toBeNull() + }) +}) + +describe('SSH Host alias identity (#10284)', () => { + it('detects SCP and ssh:// remotes as SSH transport', () => { + expect(remoteUrlUsesSshTransport('git@github-work:team/orca.git')).toBe(true) + expect(remoteUrlUsesSshTransport('ssh://git@github-work/team/orca.git')).toBe(true) + expect(remoteUrlUsesSshTransport('git+ssh://git@github-work/team/orca.git')).toBe(true) + expect(remoteUrlUsesSshTransport('https://github.com/team/orca.git')).toBe(false) + }) + + it('exposes Host aliases that need ssh -G expansion', () => { + expect(gitHubSshConfigHostAlias('git@github-work:team/orca.git')).toBe('github-work') + expect(gitHubSshConfigHostAlias('git@github.com-work:team/orca.git')).toBe('github.com-work') + expect(gitHubSshConfigHostAlias('ssh://git@github-work/team/orca.git')).toBe('github-work') + expect(gitHubSshConfigHostAlias('git@github.com:team/orca.git')).toBeNull() + expect(gitHubSshConfigHostAlias('https://github.com/team/orca.git')).toBeNull() + }) + + it('preserves SSH Host alias case for OpenSSH Host matching', () => { + expect(gitHubSshConfigHostAlias('git@GitHub-Work:team/orca.git')).toBe('GitHub-Work') + expect(gitHubSshConfigHostAlias('ssh://git@GitHub-Work/team/orca.git')).toBe('GitHub-Work') + expect(gitHubSshConfigHostAlias('git+ssh://git@GitHub-Work/team/orca.git')).toBe('GitHub-Work') + }) + + it('returns owner/repo when resolved HostName is github.com', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', 'github.com') + ).toEqual({ owner: 'team', repo: 'orca' }) + }) + + it('returns owner/repo when resolved HostName is ssh.github.com (SSH-over-HTTPS)', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', 'ssh.github.com') + ).toEqual({ owner: 'team', repo: 'orca' }) + }) + + it('keeps owner/repo for literal github.com even if resolved host is unused', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@github.com:team/orca.git', null) + ).toEqual({ owner: 'team', repo: 'orca' }) + }) + + it('returns null when resolved HostName is a non-GitHub forge', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@gitlab-work:team/orca.git', 'gitlab.com') + ).toBeNull() + }) + + it('does not apply SSH HostName resolution to HTTPS remotes', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('https://github-work/team/orca.git', 'github.com') + ).toBeNull() + }) + + it('returns null when SSH resolution is missing', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', null) + ).toBeNull() + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', ' ') + ).toBeNull() + }) + + it('normalizes effective host for enterprise routing after HostName expansion', () => { + expect(effectiveGitHubRemoteHost('github-work', 'ssh.github.com')).toBe('github.com') + expect(effectiveGitHubRemoteHost('ghe-work', 'ghe.acme.com')).toBe('ghe.acme.com') + expect(effectiveGitHubRemoteHost('github.com', null)).toBe('github.com') + }) }) diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index 6f13e62014d..67b057cb901 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -1,8 +1,17 @@ import type { GitHubOwnerRepo } from '../../shared/types' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } +export const GITHUB_REMOTE_URL_MAX_BYTES = 64 * 1024 +export const GITHUB_REMOTE_HOST_MAX_BYTES = 1024 +export const GITHUB_REMOTE_OWNER_MAX_BYTES = 256 +export const GITHUB_REMOTE_REPO_MAX_BYTES = 1024 -function normalizeGitHubRemoteHost(host: string): string { +function fitsRemoteField(value: string, maxBytes: number): boolean { + return !measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit +} + +export function normalizeGitHubRemoteHost(host: string): string { const normalizedHost = host.toLowerCase() // Why: GitHub documents ssh.github.com as SSH-over-HTTPS for github.com repos. return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost @@ -22,16 +31,69 @@ function parseGitHubRemotePath(path: string): Pick ({ + commandExecFileAsyncMock: vi.fn(), + getSshGitProviderGenerationMock: vi.fn(() => 0), + getSshGitProviderMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + resolveWithSshGMock: vi.fn(), + readLocalGitConfigSignatureMock: vi.fn(async () => 'sig-10284') +})) + +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + commandExecFileAsync: commandExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: getSshGitProviderGenerationMock +})) + +vi.mock('./local-git-config-signature', () => ({ + readLocalGitConfigSignature: readLocalGitConfigSignatureMock +})) + +vi.mock('../ssh/ssh-g-config-resolution', () => ({ + resolveWithSshG: resolveWithSshGMock +})) + +import { + getOwnerRepoForRemote, + _resetOwnerRepoCache, + _getOwnerRepoCacheSize +} from './github-repository-identity' +import { + classifyGitHubOwnerRepoFromRemoteUrl, + resolveGitHubOwnerRepoFromRemoteUrl, + _resetSshHostnameResolutionCache +} from './github-ssh-host-alias-resolution' + +const REPO = '/tmp/ssh-alias-checkout' + +function sshConfig(hostname: string, port = 22) { + return { + hostname, + port, + identityFile: [], + identitiesOnly: false, + forwardAgent: false, + proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no' + } +} + +function mockRemoteUrl(url: string): void { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${url}\n` } + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) +} + +function sshProvider(hostname: string, remoteUrl = 'git@github-work:team/orca.git') { + return { + exec: vi.fn().mockResolvedValue({ + stdout: `${remoteUrl}\n`, + stderr: '' + }), + execNonInteractive: vi.fn().mockResolvedValue({ + stdout: `hostname ${hostname}\nport 22\n`, + stderr: '', + exitCode: 0, + timedOut: false, + canceled: false + }) + } +} + +beforeEach(() => { + _resetOwnerRepoCache() + _resetSshHostnameResolutionCache() + commandExecFileAsyncMock.mockReset() + getSshGitProviderGenerationMock.mockReset() + getSshGitProviderGenerationMock.mockReturnValue(0) + getSshGitProviderMock.mockReset() + gitExecFileAsyncMock.mockReset() + resolveWithSshGMock.mockReset() + readLocalGitConfigSignatureMock.mockClear() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('#10284 SSH Host alias → github.com owner/repo', () => { + it('resolveGitHubOwnerRepoFromRemoteUrl expands HostName ssh.github.com', async () => { + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443)) + + await expect( + resolveGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git') + ).resolves.toEqual({ owner: 'team', repo: 'orca' }) + expect(resolveWithSshGMock).toHaveBeenCalledWith('github-work') + }) + + it('getOwnerRepoForRemote resolves SCP alias remote used for multi-account GitHub', async () => { + mockRemoteUrl('git@github-work:team/orca.git') + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com')) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(resolveWithSshGMock).toHaveBeenCalledWith('github-work') + }) + + it('getOwnerRepoForRemote resolves ssh:// Host alias remotes', async () => { + mockRemoteUrl('ssh://git@github.com-work/acme/widgets.git') + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443)) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'acme', + repo: 'widgets' + }) + expect(resolveWithSshGMock).toHaveBeenCalledWith('github.com-work') + }) + + it('resolves aliases inside the repository WSL runtime', async () => { + mockRemoteUrl('git@github-work:team/orca.git') + commandExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'hostname github.com\nport 22\n', + stderr: '' + }) + + await expect( + getOwnerRepoForRemote(REPO, 'origin', null, { wslDistro: 'Ubuntu' }) + ).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(commandExecFileAsyncMock).toHaveBeenCalledWith('ssh', ['-G', '--', 'github-work'], { + cwd: REPO, + timeout: 5_000, + wslDistro: 'Ubuntu' + }) + expect(resolveWithSshGMock).not.toHaveBeenCalled() + }) + + it('resolves aliases inside the repository SSH runtime', async () => { + const provider = sshProvider('github.com') + getSshGitProviderMock.mockReturnValue(provider) + getSshGitProviderGenerationMock.mockReturnValue(4) + + await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(provider.execNonInteractive).toHaveBeenCalledWith( + 'ssh', + ['-G', '--', 'github-work'], + '/remote/repo', + 5_000 + ) + expect(resolveWithSshGMock).not.toHaveBeenCalled() + }) + + it('isolates the same alias across native and WSL runtimes', async () => { + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com')) + commandExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'hostname gitlab.com\nport 22\n', + stderr: '' + }) + + await expect( + resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git') + ).resolves.toEqual({ owner: 'team', repo: 'orca' }) + await expect( + resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', { + repoPath: REPO, + wslDistro: 'Ubuntu' + }) + ).resolves.toBeNull() + expect(resolveWithSshGMock).toHaveBeenCalledTimes(1) + expect(commandExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('invalidates alias resolution when an SSH provider reconnects', async () => { + const context = { repoPath: '/remote/repo', connectionId: 'ssh-1' } + getSshGitProviderGenerationMock.mockReturnValue(1) + getSshGitProviderMock.mockReturnValue(sshProvider('github.com')) + + await expect( + resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context) + ).resolves.toEqual({ owner: 'team', repo: 'orca' }) + + getSshGitProviderGenerationMock.mockReturnValue(2) + getSshGitProviderMock.mockReturnValue(sshProvider('gitlab.com')) + await expect( + resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context) + ).resolves.toBeNull() + }) + + it('invalidates owner/repo identity when an SSH provider reconnects', async () => { + getSshGitProviderGenerationMock.mockReturnValue(1) + getSshGitProviderMock.mockReturnValue( + sshProvider('github.com', 'git@github-work:team/orca.git') + ) + + await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + + getSshGitProviderGenerationMock.mockReturnValue(2) + getSshGitProviderMock.mockReturnValue( + sshProvider('github.com', 'git@github-work:acme/widgets.git') + ) + await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ + owner: 'acme', + repo: 'widgets' + }) + }) + + it('does not call ssh -G for literal github.com remotes', async () => { + mockRemoteUrl('git@github.com:team/orca.git') + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(resolveWithSshGMock).not.toHaveBeenCalled() + }) + + it('does not call ssh -G for https remotes', async () => { + mockRemoteUrl('https://github.com/team/orca.git') + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(resolveWithSshGMock).not.toHaveBeenCalled() + }) + + it('returns null when alias resolves to a non-GitHub host', async () => { + mockRemoteUrl('git@gitlab-work:team/orca.git') + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('gitlab.com')) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() + }) + + it('returns null when ssh -G fails for an alias', async () => { + mockRemoteUrl('git@github-work:team/orca.git') + resolveWithSshGMock.mockResolvedValueOnce(null) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() + }) + + it('does not rewrite transport: identity resolution only consumes HostName', async () => { + const remote = 'git@github-work:team/orca.git' + mockRemoteUrl(remote) + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com')) + + await getOwnerRepoForRemote(REPO, 'origin') + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['remote', 'get-url', 'origin'], + expect.objectContaining({ cwd: REPO }) + ) + const gitArgLists = gitExecFileAsyncMock.mock.calls.map(([args]) => args.join(' ')) + expect(gitArgLists.every((cmd) => cmd.startsWith('remote get-url'))).toBe(true) + }) + + it('classifies ssh -G failure as indeterminate (not stable not-github)', async () => { + resolveWithSshGMock.mockResolvedValueOnce(null) + await expect( + classifyGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git') + ).resolves.toEqual({ kind: 'indeterminate' }) + }) + + it('does not long-negative-cache owner/repo when ssh -G is indeterminate', async () => { + mockRemoteUrl('git@github-work:team/orca.git') + resolveWithSshGMock.mockResolvedValue(null) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() + expect(_getOwnerRepoCacheSize()).toBe(0) + + _resetSshHostnameResolutionCache() + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() + expect(resolveWithSshGMock).toHaveBeenCalledTimes(2) + expect(_getOwnerRepoCacheSize()).toBe(0) + }) + + it('does not pin an SSH-config-dependent miss to the Git config signature', async () => { + vi.useFakeTimers() + mockRemoteUrl('git@forge-work:team/orca.git') + resolveWithSshGMock + .mockResolvedValueOnce(sshConfig('gitlab.com')) + .mockResolvedValueOnce(sshConfig('github.com')) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() + await vi.advanceTimersByTimeAsync(60_001) + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + }) + + it('caches a successful HostName expansion so repeat probes skip ssh -G', async () => { + mockRemoteUrl('git@github-work:team/orca.git') + resolveWithSshGMock.mockResolvedValue(sshConfig('github.com')) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(resolveWithSshGMock).toHaveBeenCalledTimes(1) + }) + + it('isolates case-sensitive OpenSSH Host aliases in the cache', async () => { + resolveWithSshGMock.mockImplementation(async (host: string) => + sshConfig(host === 'GitHub-Work' ? 'github.com' : 'gitlab.com') + ) + + await expect( + classifyGitHubOwnerRepoFromRemoteUrl('git@GitHub-Work:team/orca.git') + ).resolves.toEqual({ + kind: 'github', + ownerRepo: { owner: 'team', repo: 'orca' } + }) + await expect( + classifyGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git') + ).resolves.toEqual({ + kind: 'not-github', + cacheWithGitConfigSignature: false + }) + expect(resolveWithSshGMock).toHaveBeenCalledTimes(2) + }) + + it('classifies a resolved non-GitHub HostName as not-github', async () => { + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('gitlab.com')) + await expect( + classifyGitHubOwnerRepoFromRemoteUrl('git@gitlab-work:team/orca.git') + ).resolves.toEqual({ kind: 'not-github', cacheWithGitConfigSignature: false }) + }) +}) diff --git a/src/main/github/github-ssh-host-alias-resolution.ts b/src/main/github/github-ssh-host-alias-resolution.ts new file mode 100644 index 00000000000..a4988471977 --- /dev/null +++ b/src/main/github/github-ssh-host-alias-resolution.ts @@ -0,0 +1,192 @@ +import type { GitHubOwnerRepo } from '../../shared/types' +import { commandExecFileAsync } from '../git/runner' +import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' +import { parseWslPath } from '../wsl' +import { resolveWithSshG } from '../ssh/ssh-g-config-resolution' +import { + gitHubSshConfigHostAlias, + parseGitHubOwnerRepo, + parseGitHubOwnerRepoWithResolvedSshHostname +} from './github-remote-identity-parsing' + +/** `indeterminate` means SSH alias expansion failed and must remain retryable. */ +export type GitHubOwnerRepoResolution = + | { kind: 'github'; ownerRepo: GitHubOwnerRepo } + | { kind: 'not-github'; cacheWithGitConfigSignature: boolean } + | { kind: 'indeterminate' } + +const SSH_HOSTNAME_CACHE_TTL_MS = 60_000 +const SSH_HOSTNAME_FAILURE_CACHE_TTL_MS = 5_000 +const SSH_HOSTNAME_CACHE_MAX = 256 +const SSH_G_TIMEOUT_MS = 5_000 + +export type SshConfigResolutionContext = { + repoPath: string + connectionId?: string | null + wslDistro?: string +} + +type SshHostnameCacheEntry = { + hostname: string | null + resolved: boolean + expiresAt: number +} + +const sshHostnameCache = new Map() +const sshHostnameInFlight = new Map>() + +/** @internal - tests only */ +export function _resetSshHostnameResolutionCache(): void { + sshHostnameCache.clear() + sshHostnameInFlight.clear() +} + +function pruneSshHostnameCache(now: number): void { + for (const [key, entry] of sshHostnameCache) { + if (entry.expiresAt <= now) { + sshHostnameCache.delete(key) + } + } + while (sshHostnameCache.size > SSH_HOSTNAME_CACHE_MAX) { + const oldest = sshHostnameCache.keys().next().value + if (oldest === undefined) { + return + } + sshHostnameCache.delete(oldest) + } +} + +function sshRuntimeCacheKey(context: SshConfigResolutionContext): string { + if (context.connectionId) { + const generation = getSshGitProviderGeneration(context.connectionId) + return `ssh:${context.connectionId}:${generation}` + } + const distro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro + return `local:${distro?.toLowerCase() ?? 'host'}` +} + +function parseSshGHostname(stdout: string): string | null { + for (const line of stdout.split(/\r?\n/)) { + const match = line.match(/^hostname\s+(.+)$/i) + if (match?.[1].trim()) { + return match[1].trim() + } + } + return null +} + +async function resolveSshHostnameInRuntime( + host: string, + context: SshConfigResolutionContext +): Promise { + if (context.connectionId) { + const provider = getSshGitProvider(context.connectionId) + if (!provider) { + return null + } + try { + const result = await provider.execNonInteractive( + 'ssh', + ['-G', '--', host], + context.repoPath, + SSH_G_TIMEOUT_MS + ) + return result.exitCode === 0 && !result.timedOut && !result.canceled + ? parseSshGHostname(result.stdout) + : null + } catch { + return null + } + } + + const wslDistro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro + if (!wslDistro) { + return (await resolveWithSshG(host))?.hostname?.trim() || null + } + try { + const { stdout } = await commandExecFileAsync('ssh', ['-G', '--', host], { + cwd: context.repoPath, + timeout: SSH_G_TIMEOUT_MS, + wslDistro + }) + return parseSshGHostname(stdout) + } catch { + return null + } +} + +/** Resolve OpenSSH Host → HostName in the repository runtime. */ +export async function resolveSshConfigHostname( + host: string, + context: SshConfigResolutionContext = { repoPath: '' } +): Promise<{ + hostname: string | null + resolved: boolean +}> { + const cacheKey = `${sshRuntimeCacheKey(context)}\0${host}` + const now = Date.now() + pruneSshHostnameCache(now) + const cached = sshHostnameCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return { hostname: cached.hostname, resolved: cached.resolved } + } + const inFlight = sshHostnameInFlight.get(cacheKey) + if (inFlight) { + const entry = await inFlight + return { hostname: entry.hostname, resolved: entry.resolved } + } + const probe = (async (): Promise => { + const hostname = await resolveSshHostnameInRuntime(host, context) + const resolved = hostname != null && hostname.length > 0 + const entry: SshHostnameCacheEntry = { + hostname: resolved ? hostname : null, + resolved, + expiresAt: + Date.now() + (resolved ? SSH_HOSTNAME_CACHE_TTL_MS : SSH_HOSTNAME_FAILURE_CACHE_TTL_MS) + } + sshHostnameCache.set(cacheKey, entry) + pruneSshHostnameCache(Date.now()) + return entry + })() + sshHostnameInFlight.set(cacheKey, probe) + try { + const entry = await probe + return { hostname: entry.hostname, resolved: entry.resolved } + } finally { + if (sshHostnameInFlight.get(cacheKey) === probe) { + sshHostnameInFlight.delete(cacheKey) + } + } +} + +/** Resolve github.com identity without rewriting the Git transport URL. */ +export async function classifyGitHubOwnerRepoFromRemoteUrl( + remoteUrl: string, + context: SshConfigResolutionContext = { repoPath: '' } +): Promise { + const direct = parseGitHubOwnerRepo(remoteUrl) + if (direct) { + return { kind: 'github', ownerRepo: direct } + } + const aliasHost = gitHubSshConfigHostAlias(remoteUrl) + if (!aliasHost) { + return { kind: 'not-github', cacheWithGitConfigSignature: true } + } + const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context) + if (!resolved || !hostname) { + return { kind: 'indeterminate' } + } + const ownerRepo = parseGitHubOwnerRepoWithResolvedSshHostname(remoteUrl, hostname) + return ownerRepo + ? { kind: 'github', ownerRepo } + : { kind: 'not-github', cacheWithGitConfigSignature: false } +} + +/** Convenience wrapper for callers that only need owner/repo or null. */ +export async function resolveGitHubOwnerRepoFromRemoteUrl( + remoteUrl: string, + context: SshConfigResolutionContext = { repoPath: '' } +): Promise { + const result = await classifyGitHubOwnerRepoFromRemoteUrl(remoteUrl, context) + return result.kind === 'github' ? result.ownerRepo : null +} diff --git a/src/main/github/local-git-config-signature.ts b/src/main/github/local-git-config-signature.ts index 1a3c297d5f7..f76d4ee079a 100644 --- a/src/main/github/local-git-config-signature.ts +++ b/src/main/github/local-git-config-signature.ts @@ -1,7 +1,11 @@ -import { readFile, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' +import { createHash } from 'node:crypto' import { homedir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' import type { GitHubRepoContext } from './github-repository-identity' +import { cacheIdentityDigest } from '../cache-identity-digest' type LocalGitConfigPaths = { commonConfigPath: string @@ -9,6 +13,18 @@ type LocalGitConfigPaths = { } const localGitConfigSignatureInFlight = new Map>() +const LOCAL_GIT_CONFIG_SIGNATURE_MAX_IN_FLIGHT = 32 +const MAX_GIT_CONFIG_BYTES = 4 * 1024 * 1024 +const MAX_GIT_POINTER_FILE_BYTES = 64 * 1024 +const MAX_INCLUDED_CONFIG_FILES = 256 +const MAX_INCLUDED_CONFIG_DEPTH = 8 +const MAX_INCLUDED_CONFIG_PATH_BYTES = 16 * 1024 +const MAX_INCLUDED_CONFIG_AGGREGATE_PATH_BYTES = 2 * 1024 * 1024 + +type ConfigSignatureBudget = { + admittedFiles: number + pathBytes: number +} export async function readLocalGitConfigSignature( context: GitHubRepoContext @@ -18,13 +34,16 @@ export async function readLocalGitConfigSignature( // runtimes are already separated by cache key and probed through git. return undefined } - const cacheKey = context.repoPath + const cacheKey = cacheIdentityDigest([context.repoPath]) const inFlight = localGitConfigSignatureInFlight.get(cacheKey) if (inFlight) { return inFlight } const read = readUncachedLocalGitConfigSignature(context.repoPath) + if (localGitConfigSignatureInFlight.size >= LOCAL_GIT_CONFIG_SIGNATURE_MAX_IN_FLIGHT) { + return read + } localGitConfigSignatureInFlight.set(cacheKey, read) try { return await read @@ -48,31 +67,54 @@ async function readUncachedLocalGitConfigSignature(repoPath: string): Promise() + visited = new Set(), + budget: ConfigSignatureBudget = { admittedFiles: 0, pathBytes: 0 }, + depth = 0 ): Promise { if (visited.has(configPath)) { return [] } + const measuredPath = measureUtf8ByteLength(configPath, { + stopAfterBytes: MAX_INCLUDED_CONFIG_PATH_BYTES + }) + if ( + measuredPath.exceededLimit || + depth > MAX_INCLUDED_CONFIG_DEPTH || + budget.admittedFiles >= MAX_INCLUDED_CONFIG_FILES || + budget.pathBytes + measuredPath.byteLength > MAX_INCLUDED_CONFIG_AGGREGATE_PATH_BYTES + ) { + return [] + } visited.add(configPath) + budget.admittedFiles += 1 + budget.pathBytes += measuredPath.byteLength const ownSignature = await readConfigPathSignature(configPath) let configText: string try { - configText = await readFile(configPath, 'utf8') + configText = (await readNodeFileWithinLimit(configPath, MAX_GIT_CONFIG_BYTES)).buffer.toString( + 'utf8' + ) } catch { return [ownSignature] } const includedPaths = parseIncludedConfigPaths(configText, dirname(configPath)) - const includedSignatures = await Promise.all( - includedPaths.map((includedPath) => readConfigPathSignatures(includedPath, visited)) - ) - return [ownSignature, ...includedSignatures.flat()] + const signatures = [ownSignature] + for (const includedPath of includedPaths) { + signatures.push(...(await readConfigPathSignatures(includedPath, visited, budget, depth + 1))) + } + return signatures } async function readConfigPathSignature(configPath: string): Promise { @@ -103,6 +145,9 @@ function parseIncludedConfigPaths(configText: string, baseDir: string): string[] const includePath = parseIncludedConfigPath(line) if (includePath) { includedPaths.push(resolveIncludedConfigPath(includePath, baseDir)) + if (includedPaths.length >= MAX_INCLUDED_CONFIG_FILES) { + break + } } } return includedPaths @@ -214,7 +259,9 @@ async function resolveLocalGitConfigPaths(repoPath: string): Promise= PR_REFRESH_QUEUE_ENTRY_LIMIT) { + return false + } + queue.set(entry.key, entry) + return true +} + export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { outcomeObserver = observer } @@ -158,6 +185,26 @@ function recordPRRefreshQueueDiagnostic( }) } +function broadcastCapacitySkip( + aliases: GitHubPRRefreshAlias[], + reason: GitHubPRRefreshReason +): void { + diagnosticsCounters.skipped += 1 + recordPRRefreshQueueDiagnostic('skipped', reason, 'capacity') + broadcast({ aliases, reason, status: 'skipped', skippedReason: 'capacity' }) +} + +function addBoundedQueueAlias( + entry: QueueEntry, + alias: GitHubPRRefreshAlias, + reason: GitHubPRRefreshReason +): void { + const evicted = retainPRRefreshAlias(entry.aliases, alias, entry.candidate.cacheKey) + if (evicted) { + broadcastCapacitySkip([evicted], reason) + } +} + function clearActiveBurstWindow(windowId: number): void { const windowPrefix = `${windowId}::` for (const scope of Array.from(activeStartsByScope.keys())) { @@ -364,15 +411,18 @@ function visibleCandidateAfterOutcome( } } -function setVisibleFollowUp(entry: QueueEntry): void { +function setVisibleFollowUp(entry: QueueEntry): boolean { const existing = queue.get(entry.key) if (!existing) { - queue.set(entry.key, entry) - return + if (!setBoundedQueueEntry(entry)) { + broadcastCapacitySkip(Array.from(entry.aliases.values()), entry.reason) + return false + } + return true } for (const alias of entry.aliases.values()) { - existing.aliases.set(alias.cacheKey, alias) + addBoundedQueueAlias(existing, alias, entry.reason) } // Why: a user activation can arrive while a background refresh awaits gh; the follow-up must not overwrite that pending active/manual work. @@ -381,13 +431,14 @@ function setVisibleFollowUp(entry: QueueEntry): void { existing.priority > entry.priority || existing.dueAt <= entry.dueAt ) { - return + return true } - queue.set(entry.key, { + setBoundedQueueEntry({ ...entry, aliases: existing.aliases }) + return true } function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefreshAlias): void { @@ -426,7 +477,7 @@ function nextVisibleErrorRetryAt(key: string): number { const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 const retryAt = Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(failures - 1, 4)) - errorBackoff.set(key, { failures, retryAt }) + retainPRRefreshState(errorBackoff, key, { failures, retryAt }, PR_REFRESH_RETRY_STATE_LIMIT) return retryAt } @@ -463,7 +514,7 @@ function scheduleVisibleFollowUp( if (outcome.kind === 'upstream-error') { // Why: reuse the retry time already computed for the broadcast so the same failure isn't counted twice against the backoff. const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) - setVisibleFollowUp({ + const retained = setVisibleFollowUp({ key, candidate, aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), @@ -473,6 +524,10 @@ function scheduleVisibleFollowUp( queuedAt: nextQueueOrder(), windowId }) + if (!retained) { + resetKeyRetryState(key) + return + } // Why: this is a delayed retry, not active work; a spinner would make visible worktrees look stuck until backoff expires. scheduleDrain(retryAt - Date.now()) return @@ -489,7 +544,7 @@ function scheduleVisibleFollowUp( ? regularDueAt : Math.min(regularDueAt, pendingMergeabilityDueAt) // Why: a coalesced linked-PR refresh may represent several branches; preserve every alias so all cache entries keep getting updates. - setVisibleFollowUp({ + const retained = setVisibleFollowUp({ key, candidate: followUpCandidate, aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), @@ -501,6 +556,10 @@ function scheduleVisibleFollowUp( bypassBackgroundBudget: pendingMergeabilityDueAt !== null, windowId }) + if (!retained) { + resetKeyRetryState(key) + return + } scheduleDrain(Math.max(0, dueAt - Date.now())) } @@ -595,6 +654,12 @@ function pruneActiveStarts(scope: string, now: number): number[] { return activeStarts } +function pruneExpiredActiveScopes(now: number): void { + for (const scope of Array.from(activeStartsByScope.keys())) { + pruneActiveStarts(scope, now) + } +} + function nextActiveBurstDelay(entry: QueueEntry): number { const now = Date.now() const activeStarts = pruneActiveStarts(activeBurstScope(entry), now) @@ -607,9 +672,10 @@ function nextActiveBurstDelay(entry: QueueEntry): number { function noteActiveStart(entry: QueueEntry): void { const now = Date.now() const scope = activeBurstScope(entry) + pruneExpiredActiveScopes(now) const activeStarts = pruneActiveStarts(scope, now) activeStarts.push(now) - activeStartsByScope.set(scope, activeStarts) + retainPRRefreshState(activeStartsByScope, scope, activeStarts, PR_REFRESH_ACTIVE_SCOPE_LIMIT) } function activeOrder(a: QueueEntry, b: QueueEntry): number { @@ -758,7 +824,12 @@ async function drainQueue(): Promise { .find((guard) => guard.blocked) if (blockedGuard?.blocked) { const retryAt = blockedGuard.resetAt * 1000 - queue.set(next.key, { ...next, dueAt: retryAt }) + const retained = setBoundedQueueEntry({ ...next, dueAt: retryAt }) + if (!retained) { + resetKeyRetryState(next.key) + broadcastCapacitySkip(aliases, next.reason) + continue + } broadcast({ aliases, reason: next.reason, @@ -845,7 +916,7 @@ export function enqueuePRRefresh( const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) if (existing) { - existing.aliases.set(alias.cacheKey, alias) + addBoundedQueueAlias(existing, alias, reason) diagnosticsCounters.coalesced += 1 recordPRRefreshQueueDiagnostic('coalesced', reason) const shouldPromoteExisting = @@ -871,9 +942,13 @@ export function enqueuePRRefresh( } } } else { + if (queue.size >= PR_REFRESH_QUEUE_ENTRY_LIMIT) { + broadcastCapacitySkip([alias], reason) + return + } diagnosticsCounters.enqueued += 1 recordPRRefreshQueueDiagnostic('enqueued', reason) - queue.set(key, { + setBoundedQueueEntry({ key, candidate, aliases: new Map([[alias.cacheKey, alias]]), @@ -900,9 +975,13 @@ export function reportVisiblePRRefreshCandidates( if (existingVisible && generation < existingVisible.generation) { return } - visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) + const retainedCandidates = boundedVisiblePRRefreshCandidates(candidates) + visibleByWindow.set(windowId, { + generation, + keys: new Set(retainedCandidates.map(refreshKey)) + }) removeInvisibleVisibleRefreshes() - for (const candidate of candidates) { + for (const candidate of retainedCandidates) { enqueuePRRefresh(candidate, 'visible', 40, windowId) } } @@ -928,7 +1007,14 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise const key = refreshKey(candidate) const existing = queue.get(key) const aliasMap = new Map(existing ? existing.aliases : []) - aliasMap.set(alias.cacheKey, alias) + const evictedAlias = retainPRRefreshAlias( + aliasMap, + alias, + existing?.candidate.cacheKey ?? alias.cacheKey + ) + if (evictedAlias) { + broadcastCapacitySkip([evictedAlias], 'manual') + } const aliases = Array.from(aliasMap.values()) const skippedReason = validateCandidate(candidate) if (skippedReason) { @@ -963,7 +1049,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise if (gateUntil > Date.now()) { const retryAt = gateUntil // Why: paused maps `pausedUntil` into the renderer's auto-retry, so requeue at reset (finding 12) — don't advertise an unscheduled retry. - queue.set(key, { + const retained = setBoundedQueueEntry({ key, candidate, aliases: aliasMap, @@ -972,6 +1058,16 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise dueAt: retryAt, queuedAt: nextQueueOrder() }) + if (!retained) { + broadcastCapacitySkip(aliases, 'manual') + return { + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', + fetchedAt: Date.now(), + retryDisabledUntil: retryAt + } + } broadcast({ aliases, reason: 'manual', diff --git a/src/main/github/pr-refresh-memory-bounds.test.ts b/src/main/github/pr-refresh-memory-bounds.test.ts new file mode 100644 index 00000000000..9e703a6895c --- /dev/null +++ b/src/main/github/pr-refresh-memory-bounds.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubPRRefreshAlias, GitHubPRRefreshCandidate } from '../../shared/types' +import { + boundedVisiblePRRefreshCandidates, + PR_REFRESH_ALIAS_LIMIT, + PR_REFRESH_RETRY_STATE_LIMIT, + retainPRRefreshAlias, + retainPRRefreshState +} from './pr-refresh-memory-bounds' +import { PR_REFRESH_VISIBLE_CANDIDATE_LIMIT } from '../../shared/pr-refresh-memory-limits' + +function alias(index: number): GitHubPRRefreshAlias { + return { + cacheKey: `cache-${index}`, + repoId: 'repo-1', + repoPath: '/repo', + branch: `feature-${index}`, + worktreeId: `worktree-${index}`, + connectionId: null, + currentHeadOid: null, + linkedPRNumber: 42, + fallbackPRNumber: null, + fallbackPRSource: null + } +} + +function candidate(index: number): GitHubPRRefreshCandidate { + return { + cacheKey: `cache-${index}`, + repoId: 'repo-1', + repoPath: '/repo', + branch: `feature-${index}`, + worktreeId: `worktree-${index}`, + connectionId: null, + currentHeadOid: null, + linkedPRNumber: 42, + fallbackPRNumber: null, + fallbackPRSource: null, + repoKind: 'git', + cachedFetchedAt: null + } +} + +describe('PR refresh memory bounds', () => { + it('retains ordinary aliases without changing their identity or order', () => { + const aliases = new Map() + + expect(retainPRRefreshAlias(aliases, alias(0), 'cache-0')).toBeNull() + expect(retainPRRefreshAlias(aliases, alias(1), 'cache-0')).toBeNull() + + expect(Array.from(aliases.keys())).toEqual(['cache-0', 'cache-1']) + }) + + it('caps aliases while preserving the representative candidate', () => { + const aliases = new Map() + for (let index = 0; index < PR_REFRESH_ALIAS_LIMIT; index += 1) { + retainPRRefreshAlias(aliases, alias(index), 'cache-0') + } + + const evicted = retainPRRefreshAlias(aliases, alias(PR_REFRESH_ALIAS_LIMIT), 'cache-0') + + expect(aliases).toHaveLength(PR_REFRESH_ALIAS_LIMIT) + expect(aliases.has('cache-0')).toBe(true) + expect(aliases.has(`cache-${PR_REFRESH_ALIAS_LIMIT}`)).toBe(true) + expect(evicted?.cacheKey).toBe('cache-1') + }) + + it('caps retry state and refreshes existing keys without growing', () => { + const states = new Map() + for (let index = 0; index < PR_REFRESH_RETRY_STATE_LIMIT; index += 1) { + retainPRRefreshState(states, index, index, PR_REFRESH_RETRY_STATE_LIMIT) + } + + expect(retainPRRefreshState(states, 1, 99, PR_REFRESH_RETRY_STATE_LIMIT)).toBeNull() + expect( + retainPRRefreshState(states, PR_REFRESH_RETRY_STATE_LIMIT, 1, PR_REFRESH_RETRY_STATE_LIMIT) + ).toBe(0) + expect(states).toHaveLength(PR_REFRESH_RETRY_STATE_LIMIT) + expect(states.get(1)).toBe(99) + }) + + it('passes ordinary visible candidates through and truncates adversarial batches', () => { + const ordinary = [candidate(0), candidate(1)] + expect(boundedVisiblePRRefreshCandidates(ordinary)).toBe(ordinary) + + const oversized = Array.from({ length: PR_REFRESH_VISIBLE_CANDIDATE_LIMIT + 1 }, (_, index) => + candidate(index) + ) + expect(boundedVisiblePRRefreshCandidates(oversized)).toHaveLength( + PR_REFRESH_VISIBLE_CANDIDATE_LIMIT + ) + }) +}) diff --git a/src/main/github/pr-refresh-memory-bounds.ts b/src/main/github/pr-refresh-memory-bounds.ts new file mode 100644 index 00000000000..27f57ae4fdc --- /dev/null +++ b/src/main/github/pr-refresh-memory-bounds.ts @@ -0,0 +1,75 @@ +import type { GitHubPRRefreshAlias, GitHubPRRefreshCandidate } from '../../shared/types' +import { + PR_REFRESH_ALIAS_LIMIT, + PR_REFRESH_VISIBLE_CANDIDATE_LIMIT +} from '../../shared/pr-refresh-memory-limits' + +export { + PR_REFRESH_ACTIVE_SCOPE_LIMIT, + PR_REFRESH_ALIAS_LIMIT, + PR_REFRESH_QUEUE_ENTRY_LIMIT, + PR_REFRESH_RETRY_STATE_LIMIT, + PR_REFRESH_VISIBLE_CANDIDATE_LIMIT +} from '../../shared/pr-refresh-memory-limits' + +export function retainPRRefreshAlias( + aliases: Map, + alias: GitHubPRRefreshAlias, + protectedCacheKey: string +): GitHubPRRefreshAlias | null { + if (aliases.has(alias.cacheKey)) { + aliases.delete(alias.cacheKey) + aliases.set(alias.cacheKey, alias) + return null + } + if (aliases.size < PR_REFRESH_ALIAS_LIMIT) { + aliases.set(alias.cacheKey, alias) + return null + } + + let evictionKey: string | undefined + for (const cacheKey of aliases.keys()) { + if (cacheKey !== protectedCacheKey) { + evictionKey = cacheKey + break + } + } + if (evictionKey === undefined) { + return alias + } + const evicted = aliases.get(evictionKey) ?? null + aliases.delete(evictionKey) + aliases.set(alias.cacheKey, alias) + return evicted +} + +export function retainPRRefreshState( + entries: Map, + key: K, + value: V, + limit: number +): K | null { + if (entries.has(key)) { + entries.delete(key) + entries.set(key, value) + return null + } + let evictedKey: K | null = null + if (entries.size >= limit) { + const oldest = entries.keys().next() + if (!oldest.done) { + evictedKey = oldest.value + entries.delete(oldest.value) + } + } + entries.set(key, value) + return evictedKey +} + +export function boundedVisiblePRRefreshCandidates( + candidates: GitHubPRRefreshCandidate[] +): GitHubPRRefreshCandidate[] { + return candidates.length <= PR_REFRESH_VISIBLE_CANDIDATE_LIMIT + ? candidates + : candidates.slice(0, PR_REFRESH_VISIBLE_CANDIDATE_LIMIT) +} diff --git a/src/main/github/pr-start-point.test.ts b/src/main/github/pr-start-point.test.ts index 57eaacb67ba..9669ef29a93 100644 --- a/src/main/github/pr-start-point.test.ts +++ b/src/main/github/pr-start-point.test.ts @@ -111,7 +111,13 @@ describe('resolveGitHubPrStartPoint', () => { resolveRemote: async () => 'origin' }) - expect(getPullRequestPushTargetMock).toHaveBeenCalledWith('/repo-root', 1849, null) + expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( + '/repo-root', + 1849, + null, + {}, + undefined + ) expect(result).toEqual({ baseBranch: 'def456', headSha: 'def456', @@ -144,7 +150,13 @@ describe('resolveGitHubPrStartPoint', () => { resolveRemote: async () => 'origin' }) - expect(getPullRequestPushTargetMock).toHaveBeenCalledWith('/repo-root', 1849, null) + expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( + '/repo-root', + 1849, + null, + {}, + undefined + ) expect(fetchPullRequestHeadRefMock).toHaveBeenCalledWith('origin', 1849) expect(result).toEqual({ baseBranch: 'abc123', @@ -383,13 +395,21 @@ describe('resolveGitHubPrStartPoint', () => { const result = await resolveGitHubPrStartPoint({ repoPath: '/repo-root', prNumber: 1738, + issueSourcePreference: 'origin', gitExec, fetchRemoteTrackingRef, fetchPullRequestHeadRef: fetchPullRequestHeadRefMock, resolveRemote: async () => 'origin' }) - expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 1738, 'pr', null) + expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 1738, 'pr', null, {}, 'origin') + expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( + '/repo-root', + 1738, + null, + {}, + 'origin' + ) expect(result).toEqual({ baseBranch: 'abc123', compareBaseRef: 'refs/remotes/origin/main', diff --git a/src/main/github/pr-start-point.ts b/src/main/github/pr-start-point.ts index 303bc1ed08f..05c012c1ab6 100644 --- a/src/main/github/pr-start-point.ts +++ b/src/main/github/pr-start-point.ts @@ -1,4 +1,4 @@ -import type { GitHubPrStartPoint, GitPushTarget } from '../../shared/types' +import type { GitHubPrStartPoint, GitPushTarget, IssueSourcePreference } from '../../shared/types' import { fetchCompareBaseRefWithLocalFallback } from '../git/compare-base-ref-fetch' import { isMissingRemoteRefGitError, @@ -18,6 +18,7 @@ type ResolveGitHubPrStartPointArgs = { headRefName?: string baseRefName?: string isCrossRepository?: boolean + issueSourcePreference?: IssueSourcePreference connectionId?: string | null localGitOptions?: { wslDistro?: string } gitExec: GitExec @@ -30,12 +31,6 @@ type ResolveGitHubPrStartPointArgs = { type ResolveGitHubPrStartPointResult = GitHubPrStartPoint | { error: string } -function localGitOptionArgs( - options: { wslDistro?: string } | undefined -): [] | [{ wslDistro?: string }] { - return options && Object.keys(options).length > 0 ? [options] : [] -} - export async function resolveGitHubPrStartPoint( args: ResolveGitHubPrStartPointArgs ): Promise { @@ -54,7 +49,8 @@ export async function resolveGitHubPrStartPoint( args.repoPath, args.prNumber, args.connectionId ?? null, - ...localGitOptionArgs(args.localGitOptions) + args.localGitOptions ?? {}, + args.issueSourcePreference ) pushTarget = resolved?.pushTarget maintainerCanModify = resolved?.maintainerCanModify @@ -71,7 +67,8 @@ export async function resolveGitHubPrStartPoint( args.prNumber, 'pr', args.connectionId ?? null, - ...localGitOptionArgs(args.localGitOptions) + args.localGitOptions ?? {}, + args.issueSourcePreference ) if (!item || item.type !== 'pr') { return { error: `PR #${args.prNumber} not found.` } diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index 5952118ee4c..c3842da5f9e 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -68,15 +68,23 @@ export async function projectHostAuthenticationError( const OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ const REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ const REPO_SLUG_RESERVED = new Set(['.', '..']) +const OWNER_SLUG_MAX_CHARS = 256 +const REPO_SLUG_MAX_CHARS = 1024 export function isValidOwnerSlug(value: unknown): value is string { - return typeof value === 'string' && value.length > 0 && OWNER_SLUG_RE.test(value) + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= OWNER_SLUG_MAX_CHARS && + OWNER_SLUG_RE.test(value) + ) } export function isValidRepoSlug(value: unknown): value is string { return ( typeof value === 'string' && value.length > 0 && + value.length <= REPO_SLUG_MAX_CHARS && REPO_SLUG_RE.test(value) && !REPO_SLUG_RESERVED.has(value) ) diff --git a/src/main/github/review-head-remote.ts b/src/main/github/review-head-remote.ts index 2df7574712e..2a0dd53ca7f 100644 --- a/src/main/github/review-head-remote.ts +++ b/src/main/github/review-head-remote.ts @@ -1,16 +1,15 @@ +import type { IssueSourcePreference } from '../../shared/types' import { pickPreferredGitRemote } from '../../shared/preferred-git-remote' import { getDefaultRemote } from '../git/repo' import { getGitHubApiRepositoryForRemote } from './github-api-repository' type GitExec = (args: string[]) => Promise<{ stdout: string; stderr: string }> -// Why: PR work-item/API resolution probes upstream before origin -// (resolveGitHubApiRepositoryCandidates), so review-head fetches must target -// the same hosting project — a contributor clone's fork `origin` has no -// refs/pull//head for an upstream PR. Local and SSH share this resolver so -// the two surfaces cannot pick different remotes. +// Why: explicit origin must match issue listing; otherwise hosting identity +// keeps contributor clones on the upstream project's PR namespace. export async function resolveGitHubReviewHeadRemote(args: { repoPath: string + issueSourcePreference?: IssueSourcePreference connectionId?: string | null localGitOptions?: { wslDistro?: string } gitExec: GitExec @@ -20,6 +19,12 @@ export async function resolveGitHubReviewHeadRemote(args: { .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) + if (args.issueSourcePreference === 'origin') { + if (remotes.includes('origin')) { + return 'origin' + } + throw new Error('Repo has no configured origin remote.') + } // Why: identity probes cost a `remote get-url` (plus a possible gh auth // lookup) each; only multi-remote clones are ambiguous enough to need them. if (remotes.length > 1) { diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts index aca6e4a7c1c..be2576051b5 100644 --- a/src/main/github/work-item-details-enterprise-host.test.ts +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -137,7 +137,7 @@ describe('getWorkItemDetails Enterprise host routing', () => { const details = await getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1') expect(details?.body).toBe('Enterprise issue body') - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1', {}, undefined) expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(enterpriseRepository, 'graphql', { @@ -156,7 +156,7 @@ describe('getWorkItemDetails Enterprise host routing', () => { await expect(getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1')).resolves.toBeNull() - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1', {}, undefined) expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -247,7 +247,7 @@ describe('getWorkItemDetails Enterprise host routing', () => { viewerViewedState: 'VIEWED' } ]) - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1', {}, undefined) expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() expect(getPRCommentsMock).toHaveBeenCalledWith( '/remote/repo', @@ -280,7 +280,7 @@ describe('getWorkItemDetails Enterprise host routing', () => { await expect(getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1')).resolves.toBeNull() - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1', {}, undefined) expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index bc2bef2a722..11a063b51ba 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -206,7 +206,14 @@ describe('getWorkItemDetails', () => { const details = await getWorkItemDetails('/repo-root', 923, 'issue') - expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue', undefined) + expect(getWorkItemMock).toHaveBeenCalledWith( + '/repo-root', + 923, + 'issue', + undefined, + {}, + undefined + ) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api') expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql') @@ -579,7 +586,14 @@ describe('getWorkItemDetails', () => { const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1') - expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1') + expect(getWorkItemMock).toHaveBeenCalledWith( + '/home/tester/widgets', + 923, + 'issue', + 'ssh-test-1', + {}, + undefined + ) expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( '/home/tester/widgets', 'upstream', @@ -649,7 +663,14 @@ describe('getWorkItemDetails', () => { const details = await getWorkItemDetails('/repo-root', 42, 'pr', null, localGitOptions) expect(details?.body).toBe('PR body') - expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, localGitOptions) + expect(getWorkItemMock).toHaveBeenCalledWith( + '/repo-root', + 42, + 'pr', + null, + localGitOptions, + undefined + ) expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( '/repo-root', 'origin', @@ -677,6 +698,16 @@ describe('getWorkItemDetails', () => { ) }) + // Why: details open by number, so it must pin the same source as the list; + // otherwise a fork and its upstream sharing PR #42 render different PRs. + it('forwards the explicit origin source preference to the work item lookup', async () => { + getWorkItemMock.mockResolvedValueOnce(null) + + await expect(getWorkItemDetails('/repo-root', 42, 'pr', null, {}, 'origin')).resolves.toBeNull() + + expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, {}, 'origin') + }) + // Why: a rate-limited/auth-failed file fetch must not render as an empty PR; // the Files tab keys its retry state off details.filesUnavailable. it('flags filesUnavailable when the PR file fetch fails but leaves the PR empty otherwise intact', async () => { diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 5e389be8581..2df6d9200e9 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -8,6 +8,7 @@ import type { GitHubIssueTimelineTarget, GitHubWorkItem, GitHubWorkItemDetails, + IssueSourcePreference, PRCheckDetail, PRComment } from '../../shared/types' @@ -1047,14 +1048,16 @@ export async function getWorkItemDetails( number: number, type?: 'issue' | 'pr', connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference ): Promise { const item: Omit | null = await getWorkItem( repoPath, number, type, connectionId, - ...localGitOptionArgs(localGitOptions) + localGitOptions, + preference ) if (!item) { return null diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index d2f30b2d531..59a18736789 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -46,6 +46,8 @@ import { type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' +import { cacheIdentityDigest } from '../cache-identity-digest' // Why: glab REST addresses projects by URL-encoded path; escapes slashes for nested groups. function encodedProject(projectPath: string): string { @@ -54,6 +56,7 @@ function encodedProject(projectPath: string): string { const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 +export const GITLAB_RATE_LIMIT_CACHE_HOST_MAX_BYTES = 1024 const gitLabRateLimitCache = new Map() type HostedReviewLocalGitOptions = ReturnType @@ -210,9 +213,14 @@ export async function getRateLimit(options?: { host?: string | null }): Promise { const host = options?.host?.trim() || null - const cacheKey = host ?? 'default' + const retainableHost = + host === null || + !measureUtf8ByteLength(host, { + stopAfterBytes: GITLAB_RATE_LIMIT_CACHE_HOST_MAX_BYTES + }).exceededLimit + const cacheKey = cacheIdentityDigest([host ?? 'default']) pruneGitLabRateLimitCache() - const cached = gitLabRateLimitCache.get(cacheKey) + const cached = retainableHost ? gitLabRateLimitCache.get(cacheKey) : undefined if (!options?.force && cached && Date.now() - cached.fetchedAt < GITLAB_RATE_LIMIT_CACHE_TTL_MS) { return { ok: true, snapshot: cached } } @@ -223,7 +231,9 @@ export async function getRateLimit(options?: { const args = host ? ['--hostname', host, 'user'] : ['user'] const { headers } = await glabApiWithHeaders(args) const snapshot = parseGitLabRateLimitSnapshot(headers, host) - rememberGitLabRateLimitSnapshot(cacheKey, snapshot) + if (retainableHost) { + rememberGitLabRateLimitSnapshot(cacheKey, snapshot) + } return { ok: true, snapshot } } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index 1756df4ce96..920ba77f761 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -7,24 +7,160 @@ export type LocalGitExecOptions = { } const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 -const knownHostsCacheByExecutionContext = new Map() +export const GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES = 1024 * 1024 +export const GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES = 64 +export const GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES = 4 * 1024 +export const GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES = 128 * 1024 +export const GLAB_KNOWN_HOSTS_MAX_ENTRIES = 64 +export const GLAB_KNOWN_HOST_MAX_BYTES = 1024 +export const GLAB_KNOWN_HOSTS_MAX_BYTES = 32 * 1024 +export const GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT = 16 + +type KnownHostsCacheEntry = { + hosts: readonly string[] + keyBytes: number +} + +type KnownHostRetention = { + hosts: string[] + retainedBytes: number + protectedEntries: number +} + +const knownHostsCacheByExecutionContext = new Map() const knownHostsInFlightByExecutionContext = new Map>() +let knownHostsCachedContextKeyBytes = 0 + +function utf8Bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} function knownHostsExecutionKey( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): string { +): string | null { + let key: string if (connectionId) { + if (utf8Bytes(connectionId) > GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES) { + return null + } // Why: reconnecting can replace the SSH/relay execution host under the same id. - return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + key = `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + } else if (localGitOptions.wslDistro) { + if (utf8Bytes(localGitOptions.wslDistro) > GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES) { + return null + } + key = `wsl:${localGitOptions.wslDistro}` + } else { + key = 'native' } - return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'native' + return utf8Bytes(key) <= GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES ? key : null +} + +function deleteCachedContext(key: string): void { + const cached = knownHostsCacheByExecutionContext.get(key) + if (!cached) { + return + } + knownHostsCachedContextKeyBytes -= cached.keyBytes + knownHostsCacheByExecutionContext.delete(key) +} + +function getCachedHosts(key: string): readonly string[] | undefined { + const cached = knownHostsCacheByExecutionContext.get(key) + if (!cached) { + return undefined + } + knownHostsCacheByExecutionContext.delete(key) + knownHostsCacheByExecutionContext.set(key, cached) + return cached.hosts +} + +function createKnownHostRetention(includeDefaults: boolean): KnownHostRetention { + const hosts = includeDefaults ? [...DEFAULT_GITLAB_HOSTS] : [] + return { + hosts, + retainedBytes: hosts.reduce((total, host) => total + utf8Bytes(host), 0), + protectedEntries: hosts.length + } +} + +function retainKnownHost(retention: KnownHostRetention, host: string): void { + if (utf8Bytes(host) > GLAB_KNOWN_HOST_MAX_BYTES) { + return + } + const normalized = normalizeGitLabHost(host) + const hostBytes = utf8Bytes(normalized) + if ( + hostBytes === 0 || + hostBytes > GLAB_KNOWN_HOST_MAX_BYTES || + retention.hosts.includes(normalized) + ) { + return + } + while ( + retention.hosts.length > retention.protectedEntries && + (retention.hosts.length >= GLAB_KNOWN_HOSTS_MAX_ENTRIES || + retention.retainedBytes + hostBytes > GLAB_KNOWN_HOSTS_MAX_BYTES) + ) { + const removed = retention.hosts.splice(retention.protectedEntries, 1)[0] + retention.retainedBytes -= utf8Bytes(removed) + } + if ( + retention.hosts.length >= GLAB_KNOWN_HOSTS_MAX_ENTRIES || + retention.retainedBytes + hostBytes > GLAB_KNOWN_HOSTS_MAX_BYTES + ) { + return + } + retention.hosts.push(normalized) + retention.retainedBytes += hostBytes +} + +function retainKnownHosts(hosts: Iterable): readonly string[] { + const retention = createKnownHostRetention(true) + for (const host of hosts) { + retainKnownHost(retention, host) + } + return retention.hosts +} + +function cacheKnownHosts(key: string, hosts: Iterable): readonly string[] { + const retained = retainKnownHosts(hosts) + const keyBytes = utf8Bytes(key) + deleteCachedContext(key) + while ( + knownHostsCacheByExecutionContext.size >= GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES || + knownHostsCachedContextKeyBytes + keyBytes > GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES + ) { + const oldestKey = knownHostsCacheByExecutionContext.keys().next().value + if (oldestKey === undefined) { + break + } + deleteCachedContext(oldestKey) + } + knownHostsCacheByExecutionContext.set(key, { hosts: retained, keyBytes }) + knownHostsCachedContextKeyBytes += keyBytes + return retained } /** @internal - exposed for tests only */ export function _resetKnownHostsCache(): void { knownHostsCacheByExecutionContext.clear() knownHostsInFlightByExecutionContext.clear() + knownHostsCachedContextKeyBytes = 0 +} + +/** @internal - exposed for tests only */ +export function _getKnownHostsCacheState(): { + cachedContexts: number + cachedContextKeyBytes: number + inFlightContexts: number +} { + return { + cachedContexts: knownHostsCacheByExecutionContext.size, + cachedContextKeyBytes: knownHostsCachedContextKeyBytes, + inFlightContexts: knownHostsInFlightByExecutionContext.size + } } export function rememberGlabKnownHost( @@ -32,13 +168,19 @@ export function rememberGlabKnownHost( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): void { - const normalizedHost = normalizeGitLabHost(host) const key = knownHostsExecutionKey(connectionId, localGitOptions) - const cached = knownHostsCacheByExecutionContext.get(key) - if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) { + if (!key || utf8Bytes(host) > GLAB_KNOWN_HOST_MAX_BYTES) { return } - knownHostsCacheByExecutionContext.set(key, [...cached, normalizedHost]) + const normalizedHost = normalizeGitLabHost(host) + if (utf8Bytes(normalizedHost) > GLAB_KNOWN_HOST_MAX_BYTES) { + return + } + const cached = getCachedHosts(key) + if (!cached || cached.includes(normalizedHost)) { + return + } + cacheKnownHosts(key, [...cached, normalizedHost]) } export async function getGlabKnownHosts( @@ -46,7 +188,10 @@ export async function getGlabKnownHosts( localGitOptions: LocalGitExecOptions = {} ): Promise { const key = knownHostsExecutionKey(connectionId, localGitOptions) - const cached = knownHostsCacheByExecutionContext.get(key) + if (!key) { + return [...DEFAULT_GITLAB_HOSTS] + } + const cached = getCachedHosts(key) if (cached) { return cached } @@ -54,6 +199,9 @@ export async function getGlabKnownHosts( if (inFlight) { return inFlight } + if (knownHostsInFlightByExecutionContext.size >= GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT) { + return [...DEFAULT_GITLAB_HOSTS] + } const probe = probeGlabKnownHosts(key, connectionId, localGitOptions) knownHostsInFlightByExecutionContext.set(key, probe) try { @@ -75,14 +223,13 @@ async function probeGlabKnownHosts( // or reconnected SSH/relay results, and bound an otherwise global probe. const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS, + maxBuffer: GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, ...(!connectionId && localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) - const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) - knownHostsCacheByExecutionContext.set(key, merged) - return merged + return cacheKnownHosts(key, hosts) } catch { // Keep failures uncached so auth or tunnel recovery is discovered later. return [...DEFAULT_GITLAB_HOSTS] @@ -90,11 +237,11 @@ async function probeGlabKnownHosts( } export function parseGlabAuthStatusHosts(output: string): string[] { - const hosts = new Set() + const retention = createKnownHostRetention(false) // Why: self-hosted GitLab can run on a non-default port; preserve it so // services on the same hostname remain distinct downstream. for (const match of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) { - hosts.add(match[1].toLowerCase()) + retainKnownHost(retention, match[1].toLowerCase()) } for (const line of output.split('\n')) { const bareLine = line.trim() @@ -103,8 +250,8 @@ export function parseGlabAuthStatusHosts(output: string): string[] { line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine) ) { - hosts.add(hostLine.toLowerCase()) + retainKnownHost(retention, hostLine.toLowerCase()) } } - return Array.from(hosts) + return retention.hosts } diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 218a41869c9..d17108ecb9b 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -2,6 +2,10 @@ import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' import type { IssueSourcePreference } from '../../shared/types' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' +import { + buildRepositoryRefCacheKey, + RepositoryRefCache +} from '../source-control/repository-ref-cache' import { parseGlabAuthStatusHosts, rememberGlabKnownHost, @@ -18,14 +22,23 @@ import { export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef } export type { ProjectRef } export { + GLAB_KNOWN_HOST_MAX_BYTES, + GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES, + GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES, + GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES, + GLAB_KNOWN_HOSTS_MAX_BYTES, + GLAB_KNOWN_HOSTS_MAX_ENTRIES, + GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT, + GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, + _getKnownHostsCacheState, _resetKnownHostsCache, getGlabKnownHosts, - parseGlabAuthStatusHosts + parseGlabAuthStatusHosts, + rememberGlabKnownHost } from './gitlab-known-host-probe' export type { LocalGitExecOptions } from './gitlab-known-host-probe' -const PROJECT_REF_CACHE_MAX_ENTRIES = 512 -const projectRefCache = new Map() +const projectRefCache = new RepositoryRefCache() /** @internal - exposed for tests only */ export function _resetProjectRefCache(): void { @@ -38,17 +51,6 @@ export function _getProjectRefCacheSize(): number { return projectRefCache.size } -function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { - projectRefCache.set(cacheKey, value) - while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) { - const oldestKey = projectRefCache.keys().next().value - if (oldestKey === undefined) { - return - } - projectRefCache.delete(oldestKey) - } -} - export async function getProjectRefForRemote( repoPath: string, remoteName: string, @@ -57,12 +59,13 @@ export async function getProjectRefForRemote( localGitOptions: LocalGitExecOptions = {} ): Promise { const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` - if (projectRefCache.has(cacheKey)) { - return projectRefCache.get(cacheKey)! + const cacheKey = buildRepositoryRefCacheKey([runtimeKey, repoPath, remoteName, ...knownHosts]) + const cached = projectRefCache.get(cacheKey) + if (cached.found) { + return cached.value } - return runProjectRefProbeOnce(cacheKey, () => + const probe = () => resolveProjectRefForRemote( repoPath, remoteName, @@ -71,7 +74,7 @@ export async function getProjectRefForRemote( cacheKey, localGitOptions ) - ) + return cacheKey === null ? probe() : runProjectRefProbeOnce(cacheKey, probe) } async function resolveProjectRefForRemote( @@ -79,7 +82,7 @@ async function resolveProjectRefForRemote( remoteName: string, knownHosts: readonly string[], connectionId: string | null | undefined, - cacheKey: string, + cacheKey: string | null, localGitOptions: LocalGitExecOptions ): Promise { try { @@ -95,7 +98,7 @@ async function resolveProjectRefForRemote( }) const result = parseGitLabProjectRef(stdout, knownHosts) if (result) { - rememberProjectRefCacheEntry(cacheKey, result) + projectRefCache.remember(cacheKey, result, [result.host, result.path]) return result } const remoteCandidate = parseRemoteProjectRefCandidate(stdout) @@ -109,7 +112,10 @@ async function resolveProjectRefForRemote( )) ) { rememberGlabKnownHost(remoteCandidate.host, connectionId, localGitOptions) - rememberProjectRefCacheEntry(cacheKey, remoteCandidate) + projectRefCache.remember(cacheKey, remoteCandidate, [ + remoteCandidate.host, + remoteCandidate.path + ]) return remoteCandidate } } catch { @@ -117,7 +123,7 @@ async function resolveProjectRefForRemote( return null } } - rememberProjectRefCacheEntry(cacheKey, null) + projectRefCache.remember(cacheKey, null, []) return null } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 9ab99ddc441..fc34280f8e8 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -12,6 +12,15 @@ vi.mock('../git/runner', () => ({ })) import { + GLAB_KNOWN_HOST_MAX_BYTES, + GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES, + GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES, + GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES, + GLAB_KNOWN_HOSTS_MAX_BYTES, + GLAB_KNOWN_HOSTS_MAX_ENTRIES, + GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT, + GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, + _getKnownHostsCacheState, _getProjectRefCacheSize, _resetKnownHostsCache, _resetProjectRefCache, @@ -23,6 +32,7 @@ import { getProjectRefForRemote, parseGlabApiResponse, parseGlabAuthStatusHosts, + rememberGlabKnownHost, resolveIssueSource } from './gl-utils' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' @@ -434,7 +444,10 @@ describe('getGlabKnownHosts', () => { }) await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com']) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { timeout: 10_000 }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { + timeout: 10_000, + maxBuffer: GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES + }) }) it('falls back to default when glab auth status fails', async () => { @@ -495,10 +508,12 @@ describe('getGlabKnownHosts', () => { expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4) expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['auth', 'status'], { timeout: 10_000, + maxBuffer: GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, wslDistro: 'Ubuntu' }) expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['auth', 'status'], { timeout: 10_000, + maxBuffer: GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, wslDistro: 'Debian' }) }) @@ -597,4 +612,144 @@ describe('getGlabKnownHosts', () => { expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) unregisterSshGitProvider(connectionId) }) + + it('keeps the most recently used execution contexts at the cache limit', async () => { + glabExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + for (let index = 0; index < GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES; index += 1) { + await getGlabKnownHosts(undefined, { wslDistro: `Distro-${index}` }) + } + expect(_getKnownHostsCacheState().cachedContexts).toBe(GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES) + + await getGlabKnownHosts(undefined, { wslDistro: 'Distro-0' }) + await getGlabKnownHosts(undefined, { wslDistro: 'Distro-overflow' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES + 1) + + await getGlabKnownHosts(undefined, { wslDistro: 'Distro-1' }) + await getGlabKnownHosts(undefined, { wslDistro: 'Distro-0' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES + 2) + }) + + it('accepts an exact-limit execution key and skips an oversized context', async () => { + glabExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + const prefixBytes = Buffer.byteLength('wsl:', 'utf8') + const exactDistro = 'x'.repeat(GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES - prefixBytes) + + await expect(getGlabKnownHosts(undefined, { wslDistro: exactDistro })).resolves.toEqual([ + 'gitlab.com' + ]) + expect(_getKnownHostsCacheState().cachedContextKeyBytes).toBe( + GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES + ) + + await expect(getGlabKnownHosts(undefined, { wslDistro: `${exactDistro}x` })).resolves.toEqual([ + 'gitlab.com' + ]) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('evicts old large context keys at the aggregate byte limit', async () => { + glabExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + const prefixBytes = Buffer.byteLength('wsl:', 'utf8') + const retainedContexts = + GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES / GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES + const distroAt = (index: number): string => { + const suffix = String(index).padStart(3, '0') + return `${'x'.repeat(GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES - prefixBytes - suffix.length)}${suffix}` + } + + for (let index = 0; index <= retainedContexts; index += 1) { + await getGlabKnownHosts(undefined, { wslDistro: distroAt(index) }) + } + expect(_getKnownHostsCacheState()).toMatchObject({ + cachedContexts: retainedContexts, + cachedContextKeyBytes: GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES + }) + + await getGlabKnownHosts(undefined, { wslDistro: distroAt(0) }) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(retainedContexts + 2) + }) + + it('bounds parsed hosts while retaining the newest values', () => { + const hosts = Array.from( + { length: GLAB_KNOWN_HOSTS_MAX_ENTRIES + 6 }, + (_, index) => `gitlab-${index}.example.com` + ) + + expect(parseGlabAuthStatusHosts(hosts.join('\n'))).toEqual( + hosts.slice(-GLAB_KNOWN_HOSTS_MAX_ENTRIES) + ) + }) + + it('preserves gitlab.com and the newest remembered hosts at the count limit', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + await getGlabKnownHosts() + const customHosts = Array.from( + { length: GLAB_KNOWN_HOSTS_MAX_ENTRIES + 6 }, + (_, index) => `gitlab-${index}.example.com` + ) + for (const host of customHosts) { + rememberGlabKnownHost(host) + } + + await expect(getGlabKnownHosts()).resolves.toEqual([ + 'gitlab.com', + ...customHosts.slice(-(GLAB_KNOWN_HOSTS_MAX_ENTRIES - 1)) + ]) + }) + + it('accepts an exact-limit host and ignores an oversized host', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + await getGlabKnownHosts() + const exactHost = `${'x'.repeat(GLAB_KNOWN_HOST_MAX_BYTES - 5)}.test` + const oversizedHost = `${exactHost}x` + + rememberGlabKnownHost(exactHost) + rememberGlabKnownHost(oversizedHost) + + await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', exactHost]) + }) + + it('bounds aggregate retained host bytes and keeps newest values', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + await getGlabKnownHosts() + const customHosts = Array.from({ length: 40 }, (_, index) => { + const prefix = String(index).padStart(2, '0') + return `${prefix}${'x'.repeat(GLAB_KNOWN_HOST_MAX_BYTES - prefix.length)}` + }) + for (const host of customHosts) { + rememberGlabKnownHost(host) + } + + const retained = await getGlabKnownHosts() + expect(retained).toEqual(['gitlab.com', ...customHosts.slice(-31)]) + expect( + retained.reduce((total, host) => total + Buffer.byteLength(host), 0) + ).toBeLessThanOrEqual(GLAB_KNOWN_HOSTS_MAX_BYTES) + }) + + it('caps distinct in-flight probes and recovers capacity after settlement', async () => { + const resolvers: ((value: { stdout: string; stderr: string }) => void)[] = [] + glabExecFileAsyncMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }) + ) + const probes = Array.from({ length: GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT }, (_, index) => + getGlabKnownHosts(undefined, { wslDistro: `Distro-${index}` }) + ) + + expect(_getKnownHostsCacheState().inFlightContexts).toBe(GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT) + await expect(getGlabKnownHosts(undefined, { wslDistro: 'Distro-overflow' })).resolves.toEqual([ + 'gitlab.com' + ]) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT) + + for (const resolve of resolvers) { + resolve({ stdout: '', stderr: '' }) + } + await Promise.all(probes) + expect(_getKnownHostsCacheState().inFlightContexts).toBe(0) + }) }) diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index 32b25ab0717..9b6566859f4 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -10,6 +10,15 @@ export { glabExecFileAsync, gitExecFileAsync } export { classifyGlabError, classifyListIssuesError } from './glab-error-classification' export { DEFAULT_GITLAB_HOSTS, + GLAB_KNOWN_HOST_MAX_BYTES, + GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES, + GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES, + GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES, + GLAB_KNOWN_HOSTS_MAX_BYTES, + GLAB_KNOWN_HOSTS_MAX_ENTRIES, + GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT, + GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, + _getKnownHostsCacheState, _getProjectRefCacheSize, _resetKnownHostsCache, _resetProjectRefCache, @@ -21,6 +30,7 @@ export { glabRepoExecOptions, parseGlabAuthStatusHosts, parseGitLabProjectRef, + rememberGlabKnownHost, resolveIssueSource } from './gitlab-project-ref-resolution' export type { diff --git a/src/main/gitlab/merge-request-creation.ts b/src/main/gitlab/merge-request-creation.ts index 1b7d253f037..fc5a08511c3 100644 --- a/src/main/gitlab/merge-request-creation.ts +++ b/src/main/gitlab/merge-request-creation.ts @@ -1,12 +1,8 @@ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { getHostedReviewLocalGitOptions, hasHostedReviewLocalGitOptions, @@ -21,6 +17,7 @@ import { release } from './gl-utils' import { findOpenMRByHeadBase, parseMergeRequestPayload } from './merge-request-creation-lookup' +import { readGitLabMergeRequestTemplate } from '../source-control/pull-request-template' function execErrorMessage(error: unknown): string { if (error instanceof Error) { @@ -88,39 +85,6 @@ function hostedReviewExecutionOptionArgs( return hasHostedReviewLocalGitOptions(options) ? [options] : [] } -async function readMergeRequestTemplate( - repoPath: string, - connectionId?: string | null -): Promise { - const relativeCandidates = [ - '.gitlab/merge_request_templates/Default.md', - '.gitlab/merge_request_templates/default.md', - '.gitlab/merge_request_template.md', - '.gitlab/MERGE_REQUEST_TEMPLATE.md' - ] - const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - if (connectionId && !remoteProvider) { - return '' - } - for (const relativeCandidate of relativeCandidates) { - try { - if (remoteProvider) { - const result = await remoteProvider.readFile( - joinWorktreeRelativePath(repoPath, relativeCandidate) - ) - if (result.isBinary) { - continue - } - return result.content - } - return await readFile(join(repoPath, relativeCandidate), 'utf8') - } catch { - // Try the next conventional GitLab merge-request template path. - } - } - return '' -} - export async function createGitLabMergeRequest( repoPath: string, input: CreateHostedReviewInput, @@ -170,7 +134,7 @@ export async function createGitLabMergeRequest( try { const body = input.useTemplate && !input.body?.trim() - ? await readMergeRequestTemplate(repoPath, connectionId) + ? await readGitLabMergeRequestTemplate(repoPath, connectionId) : (input.body ?? '') const createArgs = [ 'mr', diff --git a/src/main/hermes/hook-service.ts b/src/main/hermes/hook-service.ts index cd4a4b99974..ccc011a35b2 100644 --- a/src/main/hermes/hook-service.ts +++ b/src/main/hermes/hook-service.ts @@ -4,7 +4,6 @@ import { copyFileSync, existsSync, mkdirSync, - readFileSync, renameSync, rmSync, unlinkSync, @@ -16,6 +15,14 @@ import type { SFTPWrapper } from 'ssh2' import { parse, stringify } from 'yaml' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + NodeFileReadTooLargeError, + readNodeFileSyncWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' +import { + AGENT_HOOK_CONFIG_MAX_BYTES, + AGENT_HOOK_PLUGIN_MAX_BYTES +} from '../agent-hooks/agent-hook-file-limits' import { readTextFileRemote, writeTextFileRemoteAtomic @@ -137,7 +144,18 @@ function readConfigFile(configPath: string): ConfigParseResult { if (!existsSync(configPath)) { return { ok: true, config: {} } } - return parseHermesConfig(readFileSync(configPath, 'utf-8')) + try { + const content = readNodeFileSyncWithinLimit( + configPath, + AGENT_HOOK_CONFIG_MAX_BYTES + ).buffer.toString('utf8') + return parseHermesConfig(content) + } catch (error) { + return { + ok: false, + detail: error instanceof Error ? error.message : String(error) + } + } } function writeConfigFile(configPath: string, config: HermesConfig): void { @@ -146,10 +164,17 @@ function writeConfigFile(configPath: string, config: HermesConfig): void { const serialized = serializeHermesConfig(config) if (existsSync(configPath)) { try { - if (readFileSync(configPath, 'utf-8') === serialized) { + const existing = readNodeFileSyncWithinLimit( + configPath, + AGENT_HOOK_CONFIG_MAX_BYTES + ).buffer.toString('utf8') + if (existing === serialized) { return } - } catch { + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw error + } // Fall through to the atomic write path. } } @@ -186,27 +211,56 @@ function updateConfigContent( function getPluginFilesState(pluginDir = getPluginDir()): { present: boolean managed: boolean + blocked: boolean detail: string | null } { const manifestPath = getManifestPath(pluginDir) const initPath = getInitPath(pluginDir) - if (!existsSync(manifestPath) || !existsSync(initPath)) { - return { present: false, managed: false, detail: 'Managed Hermes plugin files are missing' } + const manifest = readPluginFile(manifestPath, 'plugin.yaml') + const init = readPluginFile(initPath, '__init__.py') + if (manifest.kind === 'error') { + return { present: true, managed: false, blocked: manifest.blocked, detail: manifest.detail } + } + if (init.kind === 'error') { + return { present: true, managed: false, blocked: init.blocked, detail: init.detail } + } + if (manifest.kind === 'absent' || init.kind === 'absent') { + return { + present: false, + managed: false, + blocked: false, + detail: 'Managed Hermes plugin files are missing' + } + } + const managed = + manifest.content.includes(HERMES_PLUGIN_MARKER) && init.content.includes(HERMES_PLUGIN_MARKER) + return { + present: true, + managed, + blocked: false, + detail: managed ? null : 'Hermes orca-status plugin exists but is not Orca-managed' + } +} + +type PluginFileRead = + | { kind: 'absent' } + | { kind: 'content'; content: string } + | { kind: 'error'; blocked: boolean; detail: string } + +function readPluginFile(path: string, name: string): PluginFileRead { + if (!existsSync(path)) { + return { kind: 'absent' } } try { - const manifest = readFileSync(manifestPath, 'utf-8') - const init = readFileSync(initPath, 'utf-8') - const managed = manifest.includes(HERMES_PLUGIN_MARKER) && init.includes(HERMES_PLUGIN_MARKER) - return { - present: true, - managed, - detail: managed ? null : 'Hermes orca-status plugin exists but is not Orca-managed' - } + const content = readNodeFileSyncWithinLimit(path, AGENT_HOOK_PLUGIN_MAX_BYTES).buffer.toString( + 'utf8' + ) + return { kind: 'content', content } } catch (error) { return { - present: true, - managed: false, - detail: error instanceof Error ? error.message : String(error) + kind: 'error', + blocked: error instanceof NodeFileReadTooLargeError, + detail: `Could not read Hermes ${name}: ${error instanceof Error ? error.message : String(error)}` } } } @@ -234,8 +288,11 @@ function getConfigEnablement(config: HermesConfig): { } } -function buildStatus(configPath: string, config: HermesConfig): AgentHookInstallStatus { - const pluginFiles = getPluginFilesState() +function buildStatus( + configPath: string, + config: HermesConfig, + pluginFiles = getPluginFilesState() +): AgentHookInstallStatus { const enablement = getConfigEnablement(config) const details = [ pluginFiles.detail, @@ -245,7 +302,9 @@ function buildStatus(configPath: string, config: HermesConfig): AgentHookInstall ].filter((detail): detail is string => Boolean(detail)) let state: AgentHookInstallState - if (!pluginFiles.present && !enablement.enabled) { + if (pluginFiles.blocked) { + state = 'error' + } else if (!pluginFiles.present && !enablement.enabled) { state = 'not_installed' } else if ( pluginFiles.present && @@ -438,14 +497,43 @@ def register(ctx: Any) -> None: function writePluginFiles(pluginDir = getPluginDir()): void { mkdirSync(pluginDir, { recursive: true }) - writeFileSync(getManifestPath(pluginDir), getPluginManifest(), 'utf-8') - writeFileSync(getInitPath(pluginDir), getPluginInitSource(), 'utf-8') + writePluginFile(getManifestPath(pluginDir), getPluginManifest()) + writePluginFile(getInitPath(pluginDir), getPluginInitSource()) +} + +function writePluginFile(path: string, content: string): void { + if (existsSync(path)) { + try { + const existing = readNodeFileSyncWithinLimit( + path, + AGENT_HOOK_PLUGIN_MAX_BYTES + ).buffer.toString('utf8') + if (existing === content) { + return + } + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw error + } + } + } + writeFileSync(path, content, 'utf-8') } function stripTrailingSlash(path: string): string { return path.replace(/\/+$/, '') } +async function guardRemotePluginFileSize(sftp: SFTPWrapper, path: string): Promise { + try { + await readTextFileRemote(sftp, path, AGENT_HOOK_PLUGIN_MAX_BYTES) + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw error + } + } +} + export class HermesHookService { getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() @@ -474,6 +562,10 @@ export class HermesHookService { detail: `Could not parse Hermes config.yaml: ${parsed.detail}` } } + const pluginFiles = getPluginFilesState() + if (pluginFiles.blocked) { + return buildStatus(configPath, parsed.config, pluginFiles) + } writePluginFiles() writeConfigFile(configPath, enablePlugin(parsed.config)) @@ -486,6 +578,12 @@ export class HermesHookService { const remotePluginDir = `${remoteRoot}/.hermes/plugins/${HERMES_PLUGIN_NAME}` try { const existing = await readTextFileRemote(sftp, remoteConfigPath) + const remoteManifestPath = `${remotePluginDir}/plugin.yaml` + const remoteInitPath = `${remotePluginDir}/__init__.py` + await Promise.all([ + guardRemotePluginFileSize(sftp, remoteManifestPath), + guardRemotePluginFileSize(sftp, remoteInitPath) + ]) const next = updateConfigContent(existing, enablePlugin) if (next.content === null) { return { @@ -496,8 +594,18 @@ export class HermesHookService { detail: `Could not parse remote Hermes config.yaml: ${next.detail ?? 'unknown error'}` } } - await writeTextFileRemoteAtomic(sftp, `${remotePluginDir}/plugin.yaml`, getPluginManifest()) - await writeTextFileRemoteAtomic(sftp, `${remotePluginDir}/__init__.py`, getPluginInitSource()) + await writeTextFileRemoteAtomic( + sftp, + remoteManifestPath, + getPluginManifest(), + AGENT_HOOK_PLUGIN_MAX_BYTES + ) + await writeTextFileRemoteAtomic( + sftp, + remoteInitPath, + getPluginInitSource(), + AGENT_HOOK_PLUGIN_MAX_BYTES + ) await writeTextFileRemoteAtomic(sftp, remoteConfigPath, next.content) return { agent: 'hermes', @@ -530,7 +638,11 @@ export class HermesHookService { } } const pluginDir = getPluginDir() - if (getPluginFilesState(pluginDir).managed) { + const pluginFiles = getPluginFilesState(pluginDir) + if (pluginFiles.blocked) { + return buildStatus(configPath, parsed.config, pluginFiles) + } + if (pluginFiles.managed) { rmSync(pluginDir, { recursive: true, force: true }) } writeConfigFile(configPath, disablePlugin(parsed.config)) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index d117470bffe..a2e08d98a3c 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -16,6 +16,20 @@ vi.mock('fs', () => ({ chmodSync: vi.fn() })) +vi.mock('../shared/memory-safety/node-bounded-file-reader', async () => { + const fs = await import('node:fs') + return { + readNodeFileSyncWithinLimit: (path: string, maxBytes: number) => { + const value = fs.readFileSync(path) + const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value) + if (buffer.length > maxBytes) { + throw new Error('File too large') + } + return { buffer, stats: { size: buffer.length } } + } + } +}) + const { execMock, execFileMock, gitExecFileSyncMock } = vi.hoisted(() => ({ execMock: vi.fn(), execFileMock: vi.fn(), diff --git a/src/main/hooks.ts b/src/main/hooks.ts index ef5aaf1e5ea..e17939678a7 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: hook parsing, layered issue-command resolution, and cross-platform runner setup share one execution surface, so keeping them together avoids subtle drift across create/read/write paths. */ -import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { exec, execFile } from 'node:child_process' import { getDefaultRepoHookSettings } from '../shared/constants' @@ -21,8 +21,17 @@ import type { WorktreeSetupLaunch } from '../shared/types' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' +import { readNodeFileSyncWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import { MAX_ORCA_YAML_BYTES } from '../shared/orca-yaml-file-limit' const HOOK_TIMEOUT = 120_000 // 2 minutes +export { MAX_ORCA_YAML_BYTES } +export const MAX_ISSUE_COMMAND_BYTES = 1024 * 1024 +export const MAX_HOOK_GITIGNORE_BYTES = 4 * 1024 * 1024 + +function readHookFile(path: string, maxBytes: number): string { + return readNodeFileSyncWithinLimit(path, maxBytes).buffer.toString('utf8') +} export type HookRuntimeTarget = { wslDistro?: string | null @@ -48,7 +57,7 @@ export function loadHooks(repoPath: string): OrcaHooks | null { } try { - const content = readFileSync(yamlPath, 'utf-8') + const content = readHookFile(yamlPath, MAX_ORCA_YAML_BYTES) return parseOrcaYaml(content) } catch { return null @@ -73,7 +82,7 @@ const RECOGNIZED_ORCA_YAML_KEYS = new Set([ /** True when `orca.yaml` has a top-level key this version of Orca does not handle. */ export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean { try { - const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8') + const content = readHookFile(join(repoPath, 'orca.yaml'), MAX_ORCA_YAML_BYTES) for (const line of iterateLfScriptLines(content)) { // Why: match bare `key:` at end-of-line too, since a mapping with a block value on the next line is valid YAML. const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/) @@ -118,7 +127,7 @@ export function readIssueCommand(repoPath: string): ResolvedIssueCommand { if (existsSync(filePath)) { try { - const content = readFileSync(filePath, 'utf-8').trim() + const content = readHookFile(filePath, MAX_ISSUE_COMMAND_BYTES).trim() localContent = content || null } catch { localContent = null @@ -169,7 +178,7 @@ function ensureOrcaDirIgnored(repoPath: string): void { const gitignorePath = join(repoPath, '.gitignore') try { if (existsSync(gitignorePath)) { - const content = readFileSync(gitignorePath, 'utf-8') + const content = readHookFile(gitignorePath, MAX_HOOK_GITIGNORE_BYTES) if (/^\.orca\/?$/m.test(content)) { return } diff --git a/src/main/index.ts b/src/main/index.ts index d3d2a29a5c9..d0fe7a89a31 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -177,7 +177,6 @@ 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' @@ -187,7 +186,6 @@ import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branc import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename' import { moveWorktree } from './git/worktree' -import { 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' @@ -1830,13 +1828,7 @@ app.whenReady().then(async () => { const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) 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 ('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) @@ -1844,12 +1836,6 @@ app.whenReady().then(async () => { }) // 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) { diff --git a/src/main/integration-account-persistence-limits.test.ts b/src/main/integration-account-persistence-limits.test.ts new file mode 100644 index 00000000000..e81b237e049 --- /dev/null +++ b/src/main/integration-account-persistence-limits.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + assertIntegrationAccountCount, + assertIntegrationCredentialBytes, + assertIntegrationStringBytes, + MAX_INTEGRATION_ACCOUNT_FILE_BYTES, + MAX_INTEGRATION_ACCOUNTS, + MAX_INTEGRATION_CREDENTIAL_BYTES, + serializeIntegrationAccountFile +} from './integration-account-persistence-limits' + +describe('integration account persistence limits', () => { + it('admits the exact account boundary and rejects one more', () => { + expect(() => assertIntegrationAccountCount('Test', MAX_INTEGRATION_ACCOUNTS)).not.toThrow() + expect(() => assertIntegrationAccountCount('Test', MAX_INTEGRATION_ACCOUNTS + 1)).toThrow( + `at most ${MAX_INTEGRATION_ACCOUNTS}` + ) + }) + + it('measures credential and metadata fields as UTF-8 bytes', () => { + const exactCredential = 'é'.repeat(MAX_INTEGRATION_CREDENTIAL_BYTES / 2) + expect(() => assertIntegrationCredentialBytes('Test', exactCredential)).not.toThrow() + expect(() => assertIntegrationCredentialBytes('Test', `${exactCredential}a`)).toThrow( + `${MAX_INTEGRATION_CREDENTIAL_BYTES} UTF-8 bytes` + ) + + expect(() => assertIntegrationStringBytes('Test', 'field', 'éé', 4)).not.toThrow() + expect(() => assertIntegrationStringBytes('Test', 'field', 'ééa', 4)).toThrow('4 UTF-8 bytes') + }) + + it('serializes ordinary JSON identically through the bounded writer', () => { + const value = { version: 1, accounts: [{ id: 'alpha', name: 'Ada' }] } + expect(serializeIntegrationAccountFile(value)).toBe(JSON.stringify(value, null, 2)) + }) + + it('admits an exact-size metadata file and rejects one extra byte', () => { + const base = JSON.stringify({ value: '' }, null, 2) + const exact = { value: 'a'.repeat(MAX_INTEGRATION_ACCOUNT_FILE_BYTES - base.length) } + expect(Buffer.byteLength(JSON.stringify(exact, null, 2))).toBe( + MAX_INTEGRATION_ACCOUNT_FILE_BYTES + ) + expect(serializeIntegrationAccountFile(exact)).toBe(JSON.stringify(exact, null, 2)) + expect(() => serializeIntegrationAccountFile({ value: `${exact.value}a` })).toThrow( + `${MAX_INTEGRATION_ACCOUNT_FILE_BYTES} bytes` + ) + }) +}) diff --git a/src/main/integration-account-persistence-limits.ts b/src/main/integration-account-persistence-limits.ts new file mode 100644 index 00000000000..81ff072c50f --- /dev/null +++ b/src/main/integration-account-persistence-limits.ts @@ -0,0 +1,53 @@ +import { stringifyJsonWithinByteLimit } from '../shared/memory-safety/node-bounded-json-stringify' +import { MAX_INTEGRATION_CREDENTIAL_FILE_BYTES } from './integration-credential-file' + +export const MAX_INTEGRATION_ACCOUNTS = 256 +export const MAX_INTEGRATION_ACCOUNT_ID_BYTES = 128 +export const MAX_INTEGRATION_ACCOUNT_URL_BYTES = 16 * 1024 +export const MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES = 4 * 1024 +export const MAX_INTEGRATION_ACCOUNT_LABEL_BYTES = 16 * 1024 +export const MAX_INTEGRATION_CREDENTIAL_BYTES = 64 * 1024 +export const MAX_INTEGRATION_ACCOUNT_FILE_BYTES = MAX_INTEGRATION_CREDENTIAL_FILE_BYTES + +export class IntegrationAccountPersistenceLimitError extends Error { + constructor(message: string) { + super(message) + this.name = 'IntegrationAccountPersistenceLimitError' + } +} + +export function assertIntegrationAccountCount(service: string, count: number): void { + if (count > MAX_INTEGRATION_ACCOUNTS) { + throw new IntegrationAccountPersistenceLimitError( + `${service} supports at most ${MAX_INTEGRATION_ACCOUNTS} saved accounts.` + ) + } +} + +export function assertIntegrationStringBytes( + service: string, + field: string, + value: string, + maxBytes: number +): void { + if (Buffer.byteLength(value, 'utf8') > maxBytes) { + throw new IntegrationAccountPersistenceLimitError( + `${service} ${field} exceeds ${maxBytes} UTF-8 bytes.` + ) + } +} + +export function assertIntegrationCredentialBytes(service: string, value: string): void { + assertIntegrationStringBytes(service, 'credential', value, MAX_INTEGRATION_CREDENTIAL_BYTES) +} + +export function serializeIntegrationAccountFile(value: unknown): string { + return stringifyJsonWithinByteLimit(value, MAX_INTEGRATION_ACCOUNT_FILE_BYTES, 2).serialized +} + +export function unreadableIntegrationAccountFileError(service: string): Error { + return new Error( + `Saved ${service} account metadata is unreadable or exceeds supported limits; ` + + 'the existing file was left unchanged.' + ) +} diff --git a/src/main/integration-api-concurrency.test.ts b/src/main/integration-api-concurrency.test.ts new file mode 100644 index 00000000000..edb199f6987 --- /dev/null +++ b/src/main/integration-api-concurrency.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import { IntegrationApiConcurrencyGate } from './integration-api-concurrency' + +describe('IntegrationApiConcurrencyGate', () => { + it('preserves FIFO admission within the ordinary concurrency limit', async () => { + const gate = new IntegrationApiConcurrencyGate(1, 2) + const first = gate.acquire() + const secondResolved = vi.fn() + const second = gate.acquire().then(secondResolved) + + await first + expect(secondResolved).not.toHaveBeenCalled() + gate.release() + await second + + expect(secondResolved).toHaveBeenCalledTimes(1) + gate.release() + }) + + it('rejects fan-out beyond the retained waiter cap', async () => { + const gate = new IntegrationApiConcurrencyGate(1, 2) + await gate.acquire() + const queued = [gate.acquire(), gate.acquire()] + + await expect(gate.acquire()).rejects.toThrow('queue is full') + + gate.release() + await queued[0] + gate.release() + await queued[1] + gate.release() + }) +}) diff --git a/src/main/integration-api-concurrency.ts b/src/main/integration-api-concurrency.ts new file mode 100644 index 00000000000..2b44a752068 --- /dev/null +++ b/src/main/integration-api-concurrency.ts @@ -0,0 +1,34 @@ +export const INTEGRATION_API_MAX_WAITERS = 1024 + +export class IntegrationApiConcurrencyGate { + private running = 0 + private readonly waiters: (() => void)[] = [] + + constructor( + private readonly maxConcurrent: number, + private readonly maxWaiters = INTEGRATION_API_MAX_WAITERS + ) {} + + acquire(): Promise { + if (this.running < this.maxConcurrent) { + this.running += 1 + return Promise.resolve() + } + if (this.waiters.length >= this.maxWaiters) { + return Promise.reject( + new Error('Integration API request queue is full; retry after current requests finish.') + ) + } + return new Promise((resolve) => { + this.waiters.push(() => { + this.running += 1 + resolve() + }) + }) + } + + release(): void { + this.running = Math.max(0, this.running - 1) + this.waiters.shift()?.() + } +} diff --git a/src/main/integration-credential-file.test.ts b/src/main/integration-credential-file.test.ts new file mode 100644 index 00000000000..3364e0324e6 --- /dev/null +++ b/src/main/integration-credential-file.test.ts @@ -0,0 +1,46 @@ +import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + MAX_INTEGRATION_CREDENTIAL_FILE_BYTES, + readIntegrationCredentialFileSync, + readIntegrationCredentialFileText +} from './integration-credential-file' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function createPath(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-integration-credential-')) + roots.push(root) + return join(root, 'credential') +} + +describe('integration credential file bounds', () => { + it('accepts exact-cap credential bytes in sync and async readers', async () => { + const filePath = createPath() + writeFileSync(filePath, Buffer.alloc(MAX_INTEGRATION_CREDENTIAL_FILE_BYTES, 0x61)) + + expect(readIntegrationCredentialFileSync(filePath)).toHaveLength( + MAX_INTEGRATION_CREDENTIAL_FILE_BYTES + ) + await expect(readIntegrationCredentialFileText(filePath)).resolves.toHaveLength( + MAX_INTEGRATION_CREDENTIAL_FILE_BYTES + ) + }) + + it('rejects a sparse credential file beyond the cap', () => { + const filePath = createPath() + const file = openSync(filePath, 'w') + ftruncateSync(file, MAX_INTEGRATION_CREDENTIAL_FILE_BYTES + 1) + closeSync(file) + + expect(() => readIntegrationCredentialFileSync(filePath)).toThrow('exceeds') + }) +}) diff --git a/src/main/integration-credential-file.ts b/src/main/integration-credential-file.ts index 2ad90492f1b..97cbf0e6f41 100644 --- a/src/main/integration-credential-file.ts +++ b/src/main/integration-credential-file.ts @@ -1,10 +1,30 @@ import { statSync } from 'node:fs' import { safeStorage } from 'electron' +import { + readNodeFileSyncWithinLimit, + readNodeFileWithinLimit +} from '../shared/memory-safety/node-bounded-file-reader' import { credentialDecryptionMessage, type IntegrationCredentialService } from '../shared/integration-credential-errors' +export const MAX_INTEGRATION_CREDENTIAL_FILE_BYTES = 1024 * 1024 + +export function readIntegrationCredentialFileSync(filePath: string): Buffer { + return readNodeFileSyncWithinLimit(filePath, MAX_INTEGRATION_CREDENTIAL_FILE_BYTES).buffer +} + +export function readIntegrationCredentialFileSyncText(filePath: string): string { + return readIntegrationCredentialFileSync(filePath).toString('utf8') +} + +export async function readIntegrationCredentialFileText(filePath: string): Promise { + return ( + await readNodeFileWithinLimit(filePath, MAX_INTEGRATION_CREDENTIAL_FILE_BYTES) + ).buffer.toString('utf8') +} + // Why: connection status treats a token file as a saved credential; empty // files read as "missing", so counting them would split-brain getStatus. export function credentialFileHasContent(path: string): boolean { diff --git a/src/main/integration-error-message.ts b/src/main/integration-error-message.ts new file mode 100644 index 00000000000..38c533844e8 --- /dev/null +++ b/src/main/integration-error-message.ts @@ -0,0 +1,13 @@ +export const MAX_INTEGRATION_ERROR_MESSAGE_CHARS = 16 * 1024 + +export function boundedIntegrationErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + if (message.length <= MAX_INTEGRATION_ERROR_MESSAGE_CHARS) { + return message + } + return `${message.slice(0, MAX_INTEGRATION_ERROR_MESSAGE_CHARS - 1)}…` +} + +export function boundedIntegrationErrorLog(error: unknown): string { + return boundedIntegrationErrorMessage(error instanceof Error && error.stack ? error.stack : error) +} diff --git a/src/main/integration-fanout.test.ts b/src/main/integration-fanout.test.ts new file mode 100644 index 00000000000..4151db0682c --- /dev/null +++ b/src/main/integration-fanout.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + runBoundedIntegrationFanout, + runBoundedIntegrationSettledFanout +} from './integration-fanout' + +describe('runBoundedIntegrationFanout', () => { + it('bounds concurrency and preserves input order across out-of-order completions', async () => { + let active = 0 + let peak = 0 + const result = await runBoundedIntegrationFanout( + [0, 1, 2, 3, 4], + async (entry) => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolve) => setTimeout(resolve, (4 - entry) % 3)) + active -= 1 + return [entry] + }, + (items) => items, + { + maxConcurrent: 2, + limits: { maxPages: 5, maxItems: 5, maxRetainedBytes: 100 } + } + ) + + expect(peak).toBe(2) + expect(result).toEqual({ + results: [[0], [1], [2], [3], [4]], + truncated: false, + attemptedCount: 5 + }) + }) + + it('admits the exact aggregate item boundary and stops scheduling after item +1', async () => { + const visited: number[] = [] + const result = await runBoundedIntegrationFanout( + [0, 1, 2, 3], + async (entry) => { + visited.push(entry) + return [entry] + }, + (items) => items, + { + maxConcurrent: 2, + limits: { maxPages: 4, maxItems: 2, maxRetainedBytes: 100 } + } + ) + + expect(result).toEqual({ + results: [[0], [1]], + truncated: true, + attemptedCount: 2 + }) + expect(visited).toEqual([0, 1]) + }) + + it('does not retain or schedule later batches after byte +1', async () => { + const visited: number[] = [] + const result = await runBoundedIntegrationFanout( + [0, 1, 2], + async (entry) => { + visited.push(entry) + return entry === 0 ? ['a'] : ['aa'] + }, + (items) => items, + { + maxConcurrent: 1, + limits: { maxPages: 3, maxItems: 3, maxRetainedBytes: 10 } + } + ) + + expect(result).toEqual({ + results: [['a']], + truncated: true, + attemptedCount: 2 + }) + expect(visited).toEqual([0, 1]) + }) + + it('preserves settled success and failure order without rejecting the fan-out', async () => { + const result = await runBoundedIntegrationSettledFanout( + ['ok', 'bad', 'later'], + async (entry) => { + if (entry === 'bad') { + throw new Error('failed') + } + return [entry] + }, + (items) => items + ) + + expect(result.results).toMatchObject([ + { status: 'fulfilled', value: ['ok'] }, + { status: 'rejected', reason: { message: 'failed' } }, + { status: 'fulfilled', value: ['later'] } + ]) + }) +}) diff --git a/src/main/integration-fanout.ts b/src/main/integration-fanout.ts new file mode 100644 index 00000000000..a86d0f7047a --- /dev/null +++ b/src/main/integration-fanout.ts @@ -0,0 +1,82 @@ +import { MAX_INTEGRATION_ACCOUNTS } from './integration-account-persistence-limits' +import { + INTEGRATION_PAGINATION_MAX_ITEMS, + INTEGRATION_PAGINATION_MAX_RETAINED_BYTES, + IntegrationPaginationBudget, + type IntegrationPaginationLimits +} from './integration-pagination-budget' + +export const INTEGRATION_FANOUT_MAX_CONCURRENT = 4 + +const DEFAULT_FANOUT_LIMITS: IntegrationPaginationLimits = { + maxPages: MAX_INTEGRATION_ACCOUNTS, + maxItems: INTEGRATION_PAGINATION_MAX_ITEMS, + maxRetainedBytes: INTEGRATION_PAGINATION_MAX_RETAINED_BYTES +} + +export function createIntegrationFanoutBudget(): IntegrationPaginationBudget { + return new IntegrationPaginationBudget(DEFAULT_FANOUT_LIMITS) +} + +export async function runBoundedIntegrationFanout( + entries: readonly TEntry[], + load: (entry: TEntry, index: number) => Promise, + retainedValues: (result: TResult) => readonly unknown[], + options: { + budget?: IntegrationPaginationBudget + maxConcurrent?: number + limits?: IntegrationPaginationLimits + } = {} +): Promise<{ results: TResult[]; truncated: boolean; attemptedCount: number }> { + const maxConcurrent = options.maxConcurrent ?? INTEGRATION_FANOUT_MAX_CONCURRENT + if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent <= 0) { + throw new RangeError('Integration fan-out concurrency must be a positive safe integer') + } + + const budget = + options.budget ?? new IntegrationPaginationBudget(options.limits ?? DEFAULT_FANOUT_LIMITS) + const results: TResult[] = [] + let attemptedCount = 0 + for (let offset = 0; offset < entries.length; offset += maxConcurrent) { + const batch = entries.slice(offset, offset + maxConcurrent) + attemptedCount += batch.length + const loaded = await Promise.all( + batch.map((entry, batchIndex) => load(entry, offset + batchIndex)) + ) + for (const result of loaded) { + if (!budget.admitPage(retainedValues(result))) { + return { results, truncated: true, attemptedCount } + } + results.push(result) + } + if (!budget.canRequestPage && offset + batch.length < entries.length) { + return { results, truncated: true, attemptedCount } + } + } + return { results, truncated: false, attemptedCount } +} + +export function runBoundedIntegrationSettledFanout( + entries: readonly TEntry[], + load: (entry: TEntry, index: number) => Promise, + retainedValues: (result: TResult) => readonly unknown[] +): Promise<{ + results: PromiseSettledResult[] + truncated: boolean + attemptedCount: number +}> { + return runBoundedIntegrationFanout( + entries, + async (entry, index): Promise> => { + try { + return { status: 'fulfilled', value: await load(entry, index) } + } catch (reason) { + return { status: 'rejected', reason } + } + }, + (result) => + result.status === 'fulfilled' + ? retainedValues(result.value) + : [result.reason instanceof Error ? result.reason.message : String(result.reason)] + ) +} diff --git a/src/main/integration-pagination-budget.test.ts b/src/main/integration-pagination-budget.test.ts new file mode 100644 index 00000000000..fa90d052662 --- /dev/null +++ b/src/main/integration-pagination-budget.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + IntegrationPaginationBudget, + IntegrationPaginationLimitError +} from './integration-pagination-budget' + +describe('IntegrationPaginationBudget', () => { + it('admits exact page and item boundaries and rejects page +1', () => { + const budget = new IntegrationPaginationBudget({ + maxPages: 1, + maxItems: 2, + maxRetainedBytes: 100 + }) + + expect(budget.admitPage(['a', 'b'])).toBe(true) + expect(budget.canRequestPage).toBe(false) + expect(budget.admitPage([])).toBe(false) + }) + + it('admits the exact retained-byte boundary and rejects byte +1', () => { + const exact = new IntegrationPaginationBudget({ + maxPages: 2, + maxItems: 2, + maxRetainedBytes: 5 + }) + const over = new IntegrationPaginationBudget({ + maxPages: 2, + maxItems: 2, + maxRetainedBytes: 5 + }) + + expect(exact.admitPage(['a'])).toBe(true) + expect(over.admitPage(['aa'])).toBe(false) + }) + + it('does not consume capacity when a page is rejected', () => { + const budget = new IntegrationPaginationBudget({ + maxPages: 1, + maxItems: 1, + maxRetainedBytes: 5 + }) + + expect(budget.admitPage(['aa'])).toBe(false) + expect(budget.admitPage(['a'])).toBe(true) + }) + + it('bounds SDK-style cumulative pages at exact and +1 byte sizes', () => { + const exact = new IntegrationPaginationBudget({ + maxPages: 2, + maxItems: 2, + maxRetainedBytes: 5 + }) + const over = new IntegrationPaginationBudget({ + maxPages: 2, + maxItems: 2, + maxRetainedBytes: 5 + }) + + expect(() => exact.assertCumulativePage(['a'])).not.toThrow() + expect(() => over.assertCumulativePage(['aa'])).toThrow(IntegrationPaginationLimitError) + }) +}) diff --git a/src/main/integration-pagination-budget.ts b/src/main/integration-pagination-budget.ts new file mode 100644 index 00000000000..32db3dd5f64 --- /dev/null +++ b/src/main/integration-pagination-budget.ts @@ -0,0 +1,96 @@ +import { + JsonStringifyByteLimitError, + stringifyJsonWithinByteLimit +} from '../shared/memory-safety/node-bounded-json-stringify' + +export const INTEGRATION_PAGINATION_MAX_PAGES = 100 +export const INTEGRATION_PAGINATION_MAX_ITEMS = 10_000 +export const INTEGRATION_PAGINATION_MAX_RETAINED_BYTES = 32 * 1024 * 1024 + +export type IntegrationPaginationLimits = { + maxPages: number + maxItems: number + maxRetainedBytes: number +} + +const DEFAULT_LIMITS: IntegrationPaginationLimits = { + maxPages: INTEGRATION_PAGINATION_MAX_PAGES, + maxItems: INTEGRATION_PAGINATION_MAX_ITEMS, + maxRetainedBytes: INTEGRATION_PAGINATION_MAX_RETAINED_BYTES +} + +export class IntegrationPaginationLimitError extends Error { + constructor() { + super('Integration pagination exceeded its retained result budget.') + this.name = 'IntegrationPaginationLimitError' + } +} + +export class IntegrationPaginationBudget { + private pages = 0 + private items = 0 + private retainedBytes = 0 + + constructor(private readonly limits: IntegrationPaginationLimits = DEFAULT_LIMITS) {} + + admitPage(pageItems: readonly unknown[]): boolean { + if ( + this.pages >= this.limits.maxPages || + pageItems.length > this.limits.maxItems - this.items + ) { + return false + } + const pageBytes = this.measureWithinRemainingBudget(pageItems) + if (pageBytes === null) { + return false + } + this.pages += 1 + this.items += pageItems.length + this.retainedBytes += pageBytes + return true + } + + assertCumulativePage(items: readonly unknown[]): void { + if (this.pages >= this.limits.maxPages || items.length > this.limits.maxItems) { + throw new IntegrationPaginationLimitError() + } + const measuredBytes = this.measureWithinTotalBudget(items) + if (measuredBytes === null) { + throw new IntegrationPaginationLimitError() + } + this.pages += 1 + this.items = items.length + this.retainedBytes = measuredBytes + } + + get canRequestPage(): boolean { + return ( + this.pages < this.limits.maxPages && + this.items < this.limits.maxItems && + this.retainedBytes <= this.limits.maxRetainedBytes - 2 + ) + } + + private measureWithinRemainingBudget(value: unknown): number | null { + const remaining = this.limits.maxRetainedBytes - this.retainedBytes + try { + return stringifyJsonWithinByteLimit(value, remaining).byteLength + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + return null + } + throw error + } + } + + private measureWithinTotalBudget(value: unknown): number | null { + try { + return stringifyJsonWithinByteLimit(value, this.limits.maxRetainedBytes).byteLength + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + return null + } + throw error + } + } +} diff --git a/src/main/ipc/ai-vault-host-scan-batches.ts b/src/main/ipc/ai-vault-host-scan-batches.ts new file mode 100644 index 00000000000..488933bc684 --- /dev/null +++ b/src/main/ipc/ai-vault-host-scan-batches.ts @@ -0,0 +1,20 @@ +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import { mergeAiVaultListResults } from '../ai-vault/session-list-results' + +export const AI_VAULT_ALL_HOST_SCAN_CONCURRENCY = 4 + +export async function scanAiVaultHostsInBatches( + scans: readonly (() => Promise)[], + initialResults: readonly AiVaultListResult[], + limit: number | undefined +): Promise { + let merged = mergeAiVaultListResults(initialResults, limit) + for (let offset = 0; offset < scans.length; offset += AI_VAULT_ALL_HOST_SCAN_CONCURRENCY) { + const batch = scans.slice(offset, offset + AI_VAULT_ALL_HOST_SCAN_CONCURRENCY) + merged = mergeAiVaultListResults( + [merged, ...(await Promise.all(batch.map((scan) => scan())))], + limit + ) + } + return merged +} diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 5f4153106fd..820779e05f3 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { + AI_VAULT_SESSION_ID_MAX_UTF8_BYTES, + AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES +} from '../ai-vault/session-list-retention' const mocks = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn(), @@ -50,7 +54,8 @@ vi.mock('./ssh', () => ({ getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos })) -const { _internals, registerAiVaultHandlers } = await import('./ai-vault') +const { AI_VAULT_ALL_HOST_SCAN_CONCURRENCY, _internals, registerAiVaultHandlers } = + await import('./ai-vault') const provider = {} as IFilesystemProvider @@ -136,6 +141,33 @@ describe('listAiVaultSessions host routing', () => { ]) }) + it('bounds concurrent scans across a pathological runtime-host roster', async () => { + let active = 0 + let peak = 0 + mocks.getActiveSshAiVaultHostInfos.mockReturnValue([]) + mocks.scanRuntimeAiVaultSessions.mockImplementation(async (environmentId: string) => { + active += 1 + peak = Math.max(peak, active) + await Promise.resolve() + active -= 1 + return result([session(`runtime:${environmentId}`, environmentId)]) + }) + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => + Array.from({ length: 20 }, (_, index) => ({ + environmentId: `runtime-${index}`, + executionHostId: `runtime:runtime-${index}` as const + })), + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + const merged = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(20) + expect(peak).toBeLessThanOrEqual(AI_VAULT_ALL_HOST_SCAN_CONCURRENCY) + expect(merged.sessions).toHaveLength(21) + }) + it('keeps local and SSH results when runtime host discovery fails', async () => { registerAiVaultHandlers({ getActiveRuntimeAiVaultHostInfos: () => { @@ -205,6 +237,29 @@ describe('listAiVaultSessions host routing', () => { expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) }) + + it('bounds a pathological multi-host result before retaining it', async () => { + const oversized = session('ssh:dev-box', 'remote-session') + oversized.sessionId = 'x'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES + 1) + mocks.scanRemoteAiVaultSessions.mockResolvedValue(result([oversized])) + + const first = await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + const second = await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + + expect(first.sessions).toEqual([]) + expect(first.issues.at(-1)?.message).toContain('AI Vault omitted') + expect(second).toBe(first) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + }) + + it('bypasses the multi-host cache when its key exceeds the memory limit', async () => { + const scopePaths = ['x'.repeat(AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES + 1)] + + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box', scopePaths }) + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box', scopePaths }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) + }) }) describe('prepareSessionResume IPC', () => { diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index ebfd52e63a7..7c64344e541 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -11,7 +11,8 @@ import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' +import { aiVaultScanIssueResult } from '../ai-vault/session-list-results' +import * as aiVaultListRetention from '../ai-vault/session-list-retention' import type { AiVaultListArgs, AiVaultListResult, @@ -32,6 +33,9 @@ import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh' +import { scanAiVaultHostsInBatches } from './ai-vault-host-scan-batches' + +export { AI_VAULT_ALL_HOST_SCAN_CONCURRENCY } from './ai-vault-host-scan-batches' const AI_VAULT_CACHE_TTL_MS = 15_000 const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 @@ -46,9 +50,7 @@ type AiVaultHandlerOptions = AiVaultSessionSources & ) => Promise } -type RuntimeAiVaultScanOptions = { - timeoutMs?: number -} +type RuntimeAiVaultScanOptions = { timeoutMs?: number } type CachedAiVaultList = { key: string @@ -70,19 +72,21 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise { + const bounded = aiVaultListRetention.boundAiVaultListResult(result) cachedList = { key, - result, + result: bounded, expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS } - return result + return bounded }) .finally(() => { // Only clear tracking if it still refers to this request: a concurrent @@ -121,21 +126,19 @@ async function scanAiVaultSessionsByHostScope( if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] - return mergeAiVaultListResults( - await Promise.all([ - scanLocalAiVaultSessions(args), - ...getActiveSshAiVaultHostInfos().map((hostInfo) => - scanSshAiVaultSessions(hostInfo.targetId, args) - ), - ...runtimeHosts.hostInfos.map((hostInfo) => + const scans: (() => Promise)[] = [() => scanLocalAiVaultSessions(args)] + scans.push( + ...getActiveSshAiVaultHostInfos().map( + (hostInfo) => () => scanSshAiVaultSessions(hostInfo.targetId, args) + ), + ...runtimeHosts.hostInfos.map( + (hostInfo) => () => scanRuntimeAiVaultSessions(hostInfo, args, { timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS }) - ), - ...runtimeResults - ]), - args?.limit + ) ) + return scanAiVaultHostsInBatches(scans, runtimeResults, args?.limit) } const parsed = parseExecutionHostId(executionHostScope) diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 7702dcd6349..79e044dba30 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -33,12 +33,13 @@ vi.mock('node:child_process', () => ({ // events, so tests drive the async command lifecycle readCommandStdout expects. function createFakeSpawnChild(options: { stdout?: string + stdoutFragmentSize?: number code?: number error?: Error pid?: number hang?: boolean }): EventEmitter & { pid: number; kill: ReturnType; stdout: EventEmitter } { - const { stdout, code = 0, error, pid = 4242, hang = false } = options + const { stdout, stdoutFragmentSize, code = 0, error, pid = 4242, hang = false } = options const child = new EventEmitter() as EventEmitter & { pid: number kill: ReturnType @@ -58,7 +59,13 @@ function createFakeSpawnChild(options: { return } if (stdout !== undefined) { - stdoutStream.emit('data', stdout) + if (stdoutFragmentSize === undefined) { + stdoutStream.emit('data', stdout) + } else { + for (let offset = 0; offset < stdout.length; offset += stdoutFragmentSize) { + stdoutStream.emit('data', stdout.slice(offset, offset + stdoutFragmentSize)) + } + } } child.emit('close', code) }) @@ -342,6 +349,44 @@ describe('registerAppHandlers', () => { expect(spawnMock).toHaveBeenCalledTimes(2) }) + it('kills an oversized selected-input probe before using the layout fallback', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + spawnMock + .mockImplementationOnce(() => + createFakeSpawnChild({ stdout: 'x'.repeat(1024 * 1024 + 1), pid: 4242 }) + ) + .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) + registerAppHandlers({} as never) + + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( + 'com.apple.keylayout.ABC' + ) + expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL') + expect(spawnMock).toHaveBeenCalledTimes(2) + }) + + it('parses selected-input JSON after 100,000 one-byte fragments', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + const selected = JSON.stringify([ + { + 'Bundle ID': 'com.apple.inputmethod.SCIM', + 'Input Mode': 'com.apple.inputmethod.SCIM.ITABC', + InputSourceKind: 'Input Mode' + } + ]) + spawnMock.mockImplementationOnce(() => + createFakeSpawnChild({ + stdout: `${' '.repeat(100_000)}${selected}`, + stdoutFragmentSize: 1 + }) + ) + registerAppHandlers({} as never) + + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( + 'com.apple.inputmethod.SCIM.ITABC' + ) + }) + it('falls back when macOS keyboard input source probes never report completion', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true })) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 7485c03b5f0..1bc47fdc3bd 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -6,6 +6,7 @@ import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'el import { is } from '@electron-toolkit/utils' import type { AppIdentity } from '../../shared/app-identity' import type { FloatingTerminalCwdRequest, MarkdownDocument } from '../../shared/types' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import { relaunchApp } from '../app-relaunch' import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' @@ -24,6 +25,7 @@ import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint' const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500 +const KEYBOARD_INPUT_SOURCE_MAX_OUTPUT_BYTES = 1024 * 1024 const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox' // Why: defaults export reads live prefs (on-disk plist lags cfprefsd); xml1 dodges plutil's json abort on macOS 15 input-source arrays; absolute paths so a minimal PATH can't shadow the tools. const MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND = [ @@ -116,6 +118,7 @@ function readCommandStdout( return new Promise((resolve, reject) => { let settled = false let child: ReturnType | undefined + const stdout = new GrowingByteBuffer() // Why: killing only the shell orphans pipeline stages; detached spawn lets one negative-pid SIGKILL reap the whole group. const killTree = (): void => { @@ -146,14 +149,19 @@ function readCommandStdout( settled = true clearTimeout(timer) callback() + stdout.clear() } try { child = spawn(command, args, { detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) - let stdout = '' child.stdout?.setEncoding('utf8') child.stdout?.on('data', (chunk: string) => { - stdout += chunk + const bytes = Buffer.from(chunk) + if (stdout.byteLength + bytes.byteLength > KEYBOARD_INPUT_SOURCE_MAX_OUTPUT_BYTES) { + failWith(new Error(`${command} exceeded its output limit`)) + return + } + stdout.append(bytes) }) const failWith = (error: Error): void => { killTree() @@ -165,7 +173,7 @@ function readCommandStdout( child.on('close', (code, signal) => { settle(() => code === 0 - ? resolve(stdout) + ? resolve(stdout.takeString()) : reject( new Error( `${command} exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}` diff --git a/src/main/ipc/custom-pet-file-reader.test.ts b/src/main/ipc/custom-pet-file-reader.test.ts new file mode 100644 index 00000000000..d09a613d448 --- /dev/null +++ b/src/main/ipc/custom-pet-file-reader.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_CUSTOM_PET_FILE_BYTES } from '../../shared/custom-pet-media-limits' + +const { openMock } = vi.hoisted(() => ({ openMock: vi.fn() })) + +vi.mock('node:fs/promises', () => ({ open: openMock })) + +import { CustomPetFileTooLargeError, readCustomPetFile } from './custom-pet-file-reader' + +function fileHandle({ + afterSize, + beforeSize, + extraByte = false +}: { + afterSize?: number + beforeSize: number + extraByte?: boolean +}) { + let statCalls = 0 + return { + close: vi.fn().mockResolvedValue(undefined), + read: vi.fn(async (target: Buffer, offset: number, length: number, position: number) => { + if (position < beforeSize) { + const bytesRead = Math.min(length, beforeSize - position) + target.fill(0x2a, offset, offset + bytesRead) + return { bytesRead, buffer: target } + } + if (extraByte) { + target[offset] = 0x2b + return { bytesRead: 1, buffer: target } + } + return { bytesRead: 0, buffer: target } + }), + stat: vi.fn(async () => { + statCalls += 1 + return { + isFile: () => true, + size: statCalls === 1 ? beforeSize : (afterSize ?? beforeSize) + } + }) + } +} + +beforeEach(() => openMock.mockReset()) + +describe('readCustomPetFile', () => { + it('reads a stable file into an exact ArrayBuffer', async () => { + const handle = fileHandle({ beforeSize: 3 }) + openMock.mockResolvedValue(handle) + + const result = await readCustomPetFile('/stored/pet.png') + + expect([...new Uint8Array(result)]).toEqual([0x2a, 0x2a, 0x2a]) + expect(handle.close).toHaveBeenCalledOnce() + }) + + it('rejects an oversized file before issuing a read allocation', async () => { + const handle = fileHandle({ beforeSize: MAX_CUSTOM_PET_FILE_BYTES + 1 }) + openMock.mockResolvedValue(handle) + + await expect(readCustomPetFile('/stored/replaced-pet.png')).rejects.toBeInstanceOf( + CustomPetFileTooLargeError + ) + expect(handle.read).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledOnce() + }) + + it('rejects a file that grows between its handle stat and bounded read', async () => { + const handle = fileHandle({ beforeSize: 3, afterSize: 4, extraByte: true }) + openMock.mockResolvedValue(handle) + + await expect(readCustomPetFile('/stored/growing-pet.png')).rejects.toThrow( + 'changed while it was being read' + ) + expect(handle.close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/custom-pet-file-reader.ts b/src/main/ipc/custom-pet-file-reader.ts new file mode 100644 index 00000000000..c9f03db0c7d --- /dev/null +++ b/src/main/ipc/custom-pet-file-reader.ts @@ -0,0 +1,59 @@ +import { open } from 'node:fs/promises' +import { MAX_CUSTOM_PET_FILE_BYTES } from '../../shared/custom-pet-media-limits' + +const MAX_CUSTOM_PET_READ_CALLS = 1_024 + +export class CustomPetFileTooLargeError extends Error { + constructor(maxBytes: number) { + super(`Custom pet file exceeds the ${maxBytes} byte limit.`) + this.name = 'CustomPetFileTooLargeError' + } +} + +export async function readCustomPetFile( + filePath: string, + maxBytes = MAX_CUSTOM_PET_FILE_BYTES +): Promise { + const file = await open(filePath, 'r') + try { + const beforeRead = await file.stat() + if (!beforeRead.isFile()) { + throw new Error('Custom pet path is not a file.') + } + if ( + !Number.isSafeInteger(beforeRead.size) || + beforeRead.size < 0 || + beforeRead.size > maxBytes + ) { + throw new CustomPetFileTooLargeError(maxBytes) + } + + const bytes = new ArrayBuffer(beforeRead.size) + const target = Buffer.from(bytes) + let offset = 0 + let readCalls = 0 + while (offset < target.byteLength && readCalls < MAX_CUSTOM_PET_READ_CALLS) { + const result = await file.read(target, offset, target.byteLength - offset, offset) + readCalls += 1 + if (result.bytesRead === 0) { + break + } + offset += result.bytesRead + } + + const sentinel = Buffer.allocUnsafe(1) + const extra = await file.read(sentinel, 0, 1, offset) + const afterRead = await file.stat() + if ( + offset !== beforeRead.size || + extra.bytesRead !== 0 || + afterRead.size !== beforeRead.size || + bytes.byteLength > maxBytes + ) { + throw new Error('Custom pet file changed while it was being read.') + } + return bytes + } finally { + await file.close() + } +} diff --git a/src/main/ipc/emulator-frame-stream.ts b/src/main/ipc/emulator-frame-stream.ts index 4cf8eee1a5b..0907b044bd4 100644 --- a/src/main/ipc/emulator-frame-stream.ts +++ b/src/main/ipc/emulator-frame-stream.ts @@ -6,20 +6,47 @@ type FrameStreamSession = { owner: WebContents stream: MjpegFrameStream onOwnerDestroyed: () => void + nextDeliveryId: number + inFlightDeliveryId: number | null + pendingFrame: ArrayBuffer | null + errorDelivered: boolean } +export const EMULATOR_FRAME_STREAM_MAX_SESSIONS_TOTAL = 8 +export const EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER = 2 + const sessions = new Map() -function stopFrameStream(streamId: string): void { +function assertFrameStreamCapacity(owner: WebContents): void { + let ownerSessionCount = 0 + for (const session of sessions.values()) { + if (session.owner === owner) { + ownerSessionCount += 1 + } + } + if (ownerSessionCount >= EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER) { + throw new Error( + `A renderer can have at most ${EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER} active emulator frame streams.` + ) + } + if (sessions.size >= EMULATOR_FRAME_STREAM_MAX_SESSIONS_TOTAL) { + throw new Error( + `Orca can have at most ${EMULATOR_FRAME_STREAM_MAX_SESSIONS_TOTAL} active emulator frame streams.` + ) + } +} + +function stopFrameStream(streamId: string, owner: WebContents): void { const session = sessions.get(streamId) - if (!session) { + if (!session || session.owner !== owner) { return } - session.stream.stop() + sessions.delete(streamId) // Why: `.once('destroyed')` self-removes only when that event fires (window // close), so an explicit stop must drop it or each show/hide cycle leaks one. session.owner.removeListener('destroyed', session.onOwnerDestroyed) - sessions.delete(streamId) + session.pendingFrame = null + session.stream.stop() } function frameToArrayBuffer(frame: Buffer): ArrayBuffer { @@ -28,6 +55,47 @@ function frameToArrayBuffer(frame: Buffer): ArrayBuffer { return arrayBuffer } +function sendFrame(streamId: string, session: FrameStreamSession, bytes: ArrayBuffer): void { + const deliveryId = session.nextDeliveryId + session.nextDeliveryId += 1 + session.inFlightDeliveryId = deliveryId + try { + session.owner.send('emulator:frameStreamFrame', { streamId, deliveryId, bytes }) + } catch { + stopFrameStream(streamId, session.owner) + } +} + +function queueFrame(streamId: string, frame: Buffer): void { + const session = sessions.get(streamId) + if (!session || session.owner.isDestroyed()) { + return + } + const bytes = frameToArrayBuffer(frame) + if (session.inFlightDeliveryId !== null) { + session.pendingFrame = bytes + return + } + sendFrame(streamId, session, bytes) +} + +function acknowledgeFrame( + owner: WebContents, + args: { streamId: string; deliveryId: number } +): void { + const session = sessions.get(args.streamId) + if (!session || session.owner !== owner || session.inFlightDeliveryId !== args.deliveryId) { + return + } + session.inFlightDeliveryId = null + session.errorDelivered = false + const pendingFrame = session.pendingFrame + session.pendingFrame = null + if (pendingFrame) { + sendFrame(args.streamId, session, pendingFrame) + } +} + export function registerEmulatorFrameStreamHandlers(): void { ipcMain.handle( 'emulator:frameStreamStart', @@ -45,31 +113,56 @@ export function registerEmulatorFrameStreamHandlers(): void { args.streamUrl, { onError: (message) => { - if (!owner.isDestroyed()) { - owner.send('emulator:frameStreamError', { streamId, message }) + const session = sessions.get(streamId) + if (!owner.isDestroyed() && session && !session.errorDelivered) { + session.errorDelivered = true + try { + owner.send('emulator:frameStreamError', { streamId, message }) + } catch { + stopFrameStream(streamId, owner) + } } }, - onFrame: (frame) => { - if (!owner.isDestroyed()) { - owner.send('emulator:frameStreamFrame', { - streamId, - bytes: frameToArrayBuffer(frame) - }) - } - } + onFrame: (frame) => queueFrame(streamId, frame) }, args.streamKey ) - const onOwnerDestroyed = (): void => stopFrameStream(streamId) - sessions.set(streamId, { owner, stream, onOwnerDestroyed }) + assertFrameStreamCapacity(owner) + const onOwnerDestroyed = (): void => stopFrameStream(streamId, owner) + sessions.set(streamId, { + owner, + stream, + onOwnerDestroyed, + nextDeliveryId: 1, + inFlightDeliveryId: null, + pendingFrame: null, + errorDelivered: false + }) owner.once('destroyed', onOwnerDestroyed) - stream.start() + try { + stream.start() + } catch (error) { + stopFrameStream(streamId, owner) + throw error + } return { streamId } } ) - ipcMain.handle('emulator:frameStreamStop', (_event, args: { streamId: string }) => { - stopFrameStream(args.streamId) + ipcMain.handle('emulator:frameStreamStop', (event, args: { streamId: string }) => { + stopFrameStream(args.streamId, event.sender) }) + ipcMain.on( + 'emulator:frameStreamFrameAck', + (event, args: { streamId: string; deliveryId: number }) => { + if ( + typeof args?.streamId === 'string' && + Number.isSafeInteger(args.deliveryId) && + args.deliveryId > 0 + ) { + acknowledgeFrame(event.sender, args) + } + } + ) } diff --git a/src/main/ipc/emulator-stream-listener-cleanup.test.ts b/src/main/ipc/emulator-stream-listener-cleanup.test.ts index 16e332ad14a..d5cb28e9bfa 100644 --- a/src/main/ipc/emulator-stream-listener-cleanup.test.ts +++ b/src/main/ipc/emulator-stream-listener-cleanup.test.ts @@ -9,6 +9,9 @@ vi.mock('electron', () => ({ ipcMain: { handle: (channel: string, listener: (event: unknown, args: unknown) => unknown) => { handlers.set(channel, listener) + }, + on: (channel: string, listener: (event: unknown, args: unknown) => unknown) => { + handlers.set(channel, listener) } }, // Any non-null return marks the sender as a real BrowserWindow renderer. @@ -23,6 +26,8 @@ vi.mock('../emulator/mjpeg-frame-stream', () => ({ })) vi.mock('../emulator/scrcpy-video-registry', () => ({ + SCRCPY_VIDEO_MAX_GOP_FRAMES: 120, + SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE: 32 * 1024 * 1024, scrcpyVideoRegistry: { subscribe: () => () => {} } })) diff --git a/src/main/ipc/emulator-video-frame-delivery.test.ts b/src/main/ipc/emulator-video-frame-delivery.test.ts new file mode 100644 index 00000000000..ae81b2c5431 --- /dev/null +++ b/src/main/ipc/emulator-video-frame-delivery.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' +import { + SCRCPY_VIDEO_MAX_GOP_FRAMES, + SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE, + type ScrcpyVideoFrameMessage +} from '../emulator/scrcpy-video-registry' +import { EmulatorVideoFrameDelivery } from './emulator-video-frame-delivery' + +function frame( + pts: string, + byteLength = 1, + options: { config?: boolean; keyFrame?: boolean } = {} +): ScrcpyVideoFrameMessage { + return { + config: options.config === true, + keyFrame: options.keyFrame === true, + pts, + bytes: { byteLength } as ArrayBuffer + } +} + +describe('EmulatorVideoFrameDelivery', () => { + it('bounds a full cached replay behind one in-flight renderer message', () => { + const send = vi.fn() + const delivery = new EmulatorVideoFrameDelivery(send) + const replay = [ + frame('config', 1, { config: true }), + frame('key', 1, { keyFrame: true }), + ...Array.from({ length: SCRCPY_VIDEO_MAX_GOP_FRAMES - 1 }, (_, index) => + frame(`delta-${index}`) + ) + ] + + for (const replayed of replay) { + delivery.enqueue(replayed) + } + expect(send).toHaveBeenCalledTimes(1) + + for (let deliveryId = 1; deliveryId <= replay.length; deliveryId += 1) { + delivery.acknowledge(deliveryId) + } + expect(send).toHaveBeenCalledTimes(replay.length) + expect(send.mock.calls.map(([sent]) => sent.pts)).toEqual( + replay.map((replayed) => replayed.pts) + ) + }) + + it('waits for a keyframe after a stalled renderer overruns the frame bound', () => { + const send = vi.fn() + const delivery = new EmulatorVideoFrameDelivery(send) + delivery.enqueue(frame('in-flight')) + for (let index = 0; index < SCRCPY_VIDEO_MAX_GOP_FRAMES + 2; index += 1) { + delivery.enqueue(frame(`dropped-${index}`)) + } + + delivery.acknowledge(1) + delivery.enqueue(frame('still-dropped')) + expect(send).toHaveBeenCalledTimes(1) + + delivery.enqueue(frame('config', 1, { config: true })) + delivery.enqueue(frame('recovery-key', 1, { keyFrame: true })) + expect(send.mock.calls[1][0].pts).toBe('config') + delivery.acknowledge(2) + expect(send.mock.calls[2][0].pts).toBe('recovery-key') + }) + + it('recovers from a recent queued keyframe without retaining the older GOP', () => { + const send = vi.fn() + const delivery = new EmulatorVideoFrameDelivery(send) + delivery.enqueue(frame('in-flight')) + delivery.enqueue(frame('stale-config', 1, { config: true })) + for (let index = 0; index < 100; index += 1) { + delivery.enqueue(frame(`old-${index}`)) + } + delivery.enqueue(frame('new-key', 1, { keyFrame: true })) + for (let index = 0; index < 30; index += 1) { + delivery.enqueue(frame(`new-${index}`)) + } + + delivery.acknowledge(1) + + expect(send.mock.calls[1][0].pts).toBe('new-key') + }) + + it('drops an over-byte continuation and ignores stale acknowledgements', () => { + const send = vi.fn() + const delivery = new EmulatorVideoFrameDelivery(send) + delivery.enqueue(frame('in-flight-key', 1, { keyFrame: true })) + delivery.enqueue(frame('large-1', SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE / 2 + 1)) + delivery.enqueue(frame('large-2', SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE / 2 + 1)) + + delivery.acknowledge(99) + expect(send).toHaveBeenCalledTimes(1) + delivery.acknowledge(1) + expect(send).toHaveBeenCalledTimes(1) + + delivery.enqueue(frame('recovery-key', 1, { keyFrame: true })) + expect(send.mock.calls[1][0].pts).toBe('recovery-key') + }) +}) diff --git a/src/main/ipc/emulator-video-frame-delivery.ts b/src/main/ipc/emulator-video-frame-delivery.ts new file mode 100644 index 00000000000..c06e8cbb56c --- /dev/null +++ b/src/main/ipc/emulator-video-frame-delivery.ts @@ -0,0 +1,148 @@ +import { + SCRCPY_VIDEO_MAX_GOP_FRAMES, + SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE, + type ScrcpyVideoFrameMessage +} from '../emulator/scrcpy-video-registry' + +const MAX_PENDING_FRAMES = SCRCPY_VIDEO_MAX_GOP_FRAMES + 1 + +export class EmulatorVideoFrameDelivery { + private nextDeliveryId = 1 + private inFlightDeliveryId: number | null = null + private pendingFrames: ScrcpyVideoFrameMessage[] = [] + private pendingBytes = 0 + private waitingForKeyFrame = false + private waitingConfig: ScrcpyVideoFrameMessage | null = null + + constructor( + private readonly send: (frame: ScrcpyVideoFrameMessage, deliveryId: number) => void + ) {} + + enqueue(frame: ScrcpyVideoFrameMessage): void { + if (this.waitingForKeyFrame) { + this.enqueueWhileWaiting(frame) + return + } + this.pendingFrames.push(frame) + this.pendingBytes += frame.bytes.byteLength + this.recoverFromOverflow() + this.flushNext() + } + + acknowledge(deliveryId: number): void { + if (this.inFlightDeliveryId !== deliveryId) { + return + } + this.inFlightDeliveryId = null + this.flushNext() + } + + clear(): void { + this.inFlightDeliveryId = null + this.pendingFrames = [] + this.pendingBytes = 0 + this.waitingForKeyFrame = false + this.waitingConfig = null + } + + private enqueueWhileWaiting(frame: ScrcpyVideoFrameMessage): void { + if (frame.config) { + this.waitingConfig = + frame.bytes.byteLength <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE ? frame : null + return + } + if (!frame.keyFrame) { + return + } + const pendingFrames = this.waitingConfig ? [this.waitingConfig, frame] : [frame] + const pendingBytes = pendingFrames.reduce( + (total, pending) => total + pending.bytes.byteLength, + 0 + ) + this.waitingConfig = null + if (pendingBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE) { + return + } + this.waitingForKeyFrame = false + this.pendingFrames = pendingFrames + this.pendingBytes = pendingBytes + this.flushNext() + } + + private recoverFromOverflow(): void { + if ( + this.pendingFrames.length <= MAX_PENDING_FRAMES && + this.pendingBytes <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE + ) { + return + } + + const keyFrameIndex = this.findNewestKeyFrame() + if (keyFrameIndex >= 0) { + const startIndex = this.findConfigBeforeKeyFrame(keyFrameIndex) + const recoverableFrames = this.pendingFrames.slice(startIndex) + const recoverableBytes = recoverableFrames.reduce( + (total, pending) => total + pending.bytes.byteLength, + 0 + ) + if ( + recoverableFrames.length <= MAX_PENDING_FRAMES && + recoverableBytes <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE + ) { + this.pendingFrames = recoverableFrames + this.pendingBytes = recoverableBytes + return + } + } + + this.waitingConfig = this.findNewestConfig() + this.pendingFrames = [] + this.pendingBytes = 0 + this.waitingForKeyFrame = true + } + + private findNewestKeyFrame(): number { + for (let index = this.pendingFrames.length - 1; index >= 0; index -= 1) { + if (this.pendingFrames[index].keyFrame) { + return index + } + } + return -1 + } + + private findConfigBeforeKeyFrame(keyFrameIndex: number): number { + let startIndex = keyFrameIndex + for (let index = keyFrameIndex - 1; index >= 0; index -= 1) { + if (!this.pendingFrames[index].config) { + break + } + startIndex = index + } + return startIndex + } + + private findNewestConfig(): ScrcpyVideoFrameMessage | null { + for (let index = this.pendingFrames.length - 1; index >= 0; index -= 1) { + const frame = this.pendingFrames[index] + if (frame.config && frame.bytes.byteLength <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE) { + return frame + } + } + return null + } + + private flushNext(): void { + if (this.inFlightDeliveryId !== null || this.pendingFrames.length === 0) { + return + } + const frame = this.pendingFrames.shift() + if (!frame) { + return + } + this.pendingBytes -= frame.bytes.byteLength + const deliveryId = this.nextDeliveryId + this.nextDeliveryId += 1 + this.inFlightDeliveryId = deliveryId + this.send(frame, deliveryId) + } +} diff --git a/src/main/ipc/emulator-video-stream-admission.test.ts b/src/main/ipc/emulator-video-stream-admission.test.ts new file mode 100644 index 00000000000..a2220ce03a2 --- /dev/null +++ b/src/main/ipc/emulator-video-stream-admission.test.ts @@ -0,0 +1,243 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type VideoEvent = + | { type: 'meta'; meta: { codecId: string; width: number; height: number } } + | { + type: 'frame' + frame: { + config: boolean + keyFrame: boolean + pts: string + bytes: ArrayBuffer + } + } + +const { handlers, registryState, subscribeMock } = vi.hoisted(() => { + const state = { + error: null as Error | null, + subscribers: [] as ((event: VideoEvent) => void)[], + unsubscribes: [] as ReturnType[] + } + return { + handlers: new Map unknown>(), + registryState: state, + subscribeMock: vi.fn((_deviceId: string, subscriber: (event: VideoEvent) => void) => { + if (state.error) { + throw state.error + } + const unsubscribe = vi.fn() + state.subscribers.push(subscriber) + state.unsubscribes.push(unsubscribe) + return unsubscribe + }) + } +}) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, listener: (event: unknown, args: unknown) => unknown): void => { + handlers.set(channel, listener) + }, + on: (channel: string, listener: (event: unknown, args: unknown) => unknown): void => { + handlers.set(channel, listener) + } + }, + BrowserWindow: { + fromWebContents: (owner: { isDestroyed: () => boolean }) => (owner.isDestroyed() ? null : {}) + } +})) + +vi.mock('../emulator/scrcpy-video-registry', () => ({ + SCRCPY_VIDEO_MAX_GOP_FRAMES: 120, + SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE: 32 * 1024 * 1024, + scrcpyVideoRegistry: { subscribe: subscribeMock } +})) + +vi.mock('../emulator/emulator-probe', () => ({ emulatorProbe: vi.fn() })) + +import { + EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER, + EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_TOTAL, + registerEmulatorVideoStreamHandlers +} from './emulator-video-stream' + +type Owner = EventEmitter & { + destroy: () => void + isDestroyed: () => boolean + send: ReturnType +} + +const owners: Owner[] = [] + +function makeOwner(): Owner { + let destroyed = false + const owner = new EventEmitter() as Owner + owner.isDestroyed = () => destroyed + owner.send = vi.fn() + owner.destroy = () => { + if (destroyed) { + return + } + destroyed = true + owner.emit('destroyed') + } + owners.push(owner) + return owner +} + +function handler(channel: string): (event: unknown, args: unknown) => unknown { + const registered = handlers.get(channel) + if (!registered) { + throw new Error(`Missing handler for ${channel}`) + } + return registered +} + +function startStream(owner: Owner, streamId: string): void { + handler('emulator:videoStreamStart')({ sender: owner }, { deviceId: 'emulator-5554', streamId }) +} + +function stopStream(owner: Owner, streamId: string): void { + handler('emulator:videoStreamStop')({ sender: owner }, { streamId }) +} + +beforeEach(() => { + vi.useFakeTimers() + handlers.clear() + registryState.error = null + registryState.subscribers.length = 0 + registryState.unsubscribes.length = 0 + subscribeMock.mockClear() + registerEmulatorVideoStreamHandlers() +}) + +afterEach(() => { + for (const owner of owners) { + owner.destroy() + } + owners.length = 0 + vi.runOnlyPendingTimers() + vi.useRealTimers() +}) + +describe('emulator video stream admission', () => { + it('caps subscriptions per renderer without affecting another renderer', () => { + const owner = makeOwner() + for (let index = 0; index < EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER; index += 1) { + startStream(owner, `owned-${index}`) + } + + expect(() => startStream(owner, 'overflow')).toThrow(/renderer can have at most/) + expect(() => startStream(makeOwner(), 'other-renderer')).not.toThrow() + }) + + it('caps subscriptions across renderers before registry fan-out begins', () => { + const ownerCount = + EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_TOTAL / + EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER + for (let ownerIndex = 0; ownerIndex < ownerCount; ownerIndex += 1) { + const owner = makeOwner() + for ( + let streamIndex = 0; + streamIndex < EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER; + streamIndex += 1 + ) { + startStream(owner, `${ownerIndex}-${streamIndex}`) + } + } + + expect(() => startStream(makeOwner(), 'overflow')).toThrow(/Orca can have at most/) + expect(subscribeMock).not.toHaveBeenCalled() + }) + + it('allows owned replacement and stop without cross-renderer capacity changes', () => { + const owner = makeOwner() + const otherOwner = makeOwner() + startStream(owner, 'first') + startStream(owner, 'second') + vi.runOnlyPendingTimers() + const firstUnsubscribe = registryState.unsubscribes[0] + + startStream(owner, 'first') + expect(firstUnsubscribe).toHaveBeenCalledOnce() + expect(owner.listenerCount('destroyed')).toBe(2) + + stopStream(otherOwner, 'first') + expect(() => startStream(owner, 'overflow')).toThrow(/renderer can have at most/) + + stopStream(owner, 'first') + expect(() => startStream(owner, 'replacement')).not.toThrow() + }) + + it('releases pending subscriptions when the renderer is destroyed', () => { + const owner = makeOwner() + startStream(owner, 'first') + startStream(owner, 'second') + + owner.destroy() + vi.runOnlyPendingTimers() + + expect(owner.listenerCount('destroyed')).toBe(0) + expect(subscribeMock).not.toHaveBeenCalled() + const nextOwner = makeOwner() + expect(() => startStream(nextOwner, 'next-first')).not.toThrow() + expect(() => startStream(nextOwner, 'next-second')).not.toThrow() + }) + + it('releases admission when synchronous registry replay throws', () => { + const owner = makeOwner() + registryState.error = new Error('renderer send failed') + startStream(owner, 'failing') + + vi.runOnlyPendingTimers() + + expect(owner.listenerCount('destroyed')).toBe(0) + registryState.error = null + for (let index = 0; index < EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER; index += 1) { + expect(() => startStream(owner, `recovered-${index}`)).not.toThrow() + } + }) + + it('sends one H.264 frame at a time and requires the exact delivery token', () => { + const owner = makeOwner() + startStream(owner, 'bounded') + vi.runOnlyPendingTimers() + const subscriber = registryState.subscribers[0] + + for (let index = 0; index < 20; index += 1) { + subscriber({ + type: 'frame', + frame: { + config: false, + keyFrame: index === 0, + pts: String(index), + bytes: new ArrayBuffer(1) + } + }) + } + const frameCalls = () => + owner.send.mock.calls.filter(([channel]) => channel === 'emulator:videoStreamFrame') + expect(frameCalls()).toHaveLength(1) + const firstPayload = frameCalls()[0][1] as { + deliveryToken: string + deliveryId: number + } + + handler('emulator:videoStreamFrameAck')( + { sender: owner }, + { streamId: 'bounded', deliveryToken: 'stale', deliveryId: 1 } + ) + expect(frameCalls()).toHaveLength(1) + + handler('emulator:videoStreamFrameAck')( + { sender: owner }, + { + streamId: 'bounded', + deliveryToken: firstPayload.deliveryToken, + deliveryId: firstPayload.deliveryId + } + ) + expect(frameCalls()).toHaveLength(2) + }) +}) diff --git a/src/main/ipc/emulator-video-stream.ts b/src/main/ipc/emulator-video-stream.ts index 8bfe8367103..9bf93f124bc 100644 --- a/src/main/ipc/emulator-video-stream.ts +++ b/src/main/ipc/emulator-video-stream.ts @@ -2,6 +2,10 @@ import { BrowserWindow, ipcMain, type WebContents } from 'electron' import { randomUUID } from 'node:crypto' import { scrcpyVideoRegistry } from '../emulator/scrcpy-video-registry' import { emulatorProbe } from '../emulator/emulator-probe' +import { EmulatorVideoFrameDelivery } from './emulator-video-frame-delivery' + +export const EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_TOTAL = 8 +export const EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER = 2 // Bridges the main-process scrcpy video registry to renderer subscribers. The // renderer calls emulator:videoStreamStart with a deviceId; meta + H.264 access @@ -12,19 +16,41 @@ export function registerEmulatorVideoStreamHandlers(): void { owner: WebContents unsubscribe: () => void onOwnerDestroyed: () => void + delivery: EmulatorVideoFrameDelivery + deliveryToken: string } const subscriptions = new Map() + const assertSubscriptionCapacity = (owner: WebContents): void => { + let ownerSubscriptionCount = 0 + for (const subscription of subscriptions.values()) { + if (subscription.owner === owner) { + ownerSubscriptionCount += 1 + } + } + if (ownerSubscriptionCount >= EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER) { + throw new Error( + `A renderer can have at most ${EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER} active emulator video streams.` + ) + } + if (subscriptions.size >= EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_TOTAL) { + throw new Error( + `Orca can have at most ${EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_TOTAL} active emulator video streams.` + ) + } + } + const stopSubscription = (streamId: string, owner?: WebContents): void => { const subscription = subscriptions.get(streamId) if (!subscription || (owner && subscription.owner !== owner)) { return } - subscription.unsubscribe() + subscriptions.delete(streamId) // Why: `.once('destroyed')` self-removes only when that event fires (window // close), so an explicit stop must drop it or each show/hide cycle leaks one. subscription.owner.removeListener('destroyed', subscription.onOwnerDestroyed) - subscriptions.delete(streamId) + subscription.delivery.clear() + subscription.unsubscribe() } ipcMain.handle( @@ -44,36 +70,65 @@ export function registerEmulatorVideoStreamHandlers(): void { throw new Error('Video stream id is already in use by another renderer') } stopSubscription(streamId, owner) + assertSubscriptionCapacity(owner) const onOwnerDestroyed = (): void => stopSubscription(streamId, owner) + const deliveryToken = randomUUID() + const delivery = new EmulatorVideoFrameDelivery((frame, deliveryId) => { + const current = subscriptions.get(streamId) + if (!current || current.delivery !== delivery || owner.isDestroyed()) { + return + } + try { + owner.send('emulator:videoStreamFrame', { + streamId, + deliveryToken, + deliveryId, + deviceId: args.deviceId, + ...frame + }) + } catch { + stopSubscription(streamId, owner) + } + }) const pendingSubscription: Subscription = { owner, unsubscribe: () => {}, - onOwnerDestroyed + onOwnerDestroyed, + delivery, + deliveryToken } subscriptions.set(streamId, pendingSubscription) setTimeout(() => { if (owner.isDestroyed() || subscriptions.get(streamId) !== pendingSubscription) { return } - const unsubscribe = scrcpyVideoRegistry.subscribe(args.deviceId, (videoEvent) => { - if (owner.isDestroyed()) { + try { + const unsubscribe = scrcpyVideoRegistry.subscribe(args.deviceId, (videoEvent) => { + if (owner.isDestroyed() || subscriptions.get(streamId) !== pendingSubscription) { + return + } + if (videoEvent.type === 'meta') { + try { + owner.send('emulator:videoStreamMeta', { + streamId, + deviceId: args.deviceId, + meta: videoEvent.meta + }) + } catch { + stopSubscription(streamId, owner) + } + } else { + delivery.enqueue(videoEvent.frame) + } + }) + if (subscriptions.get(streamId) !== pendingSubscription) { + unsubscribe() return } - if (videoEvent.type === 'meta') { - owner.send('emulator:videoStreamMeta', { - streamId, - deviceId: args.deviceId, - meta: videoEvent.meta - }) - } else { - owner.send('emulator:videoStreamFrame', { - streamId, - deviceId: args.deviceId, - ...videoEvent.frame - }) - } - }) - pendingSubscription.unsubscribe = unsubscribe + pendingSubscription.unsubscribe = unsubscribe + } catch { + stopSubscription(streamId, owner) + } }, 0) owner.once('destroyed', onOwnerDestroyed) return { streamId } @@ -83,4 +138,19 @@ export function registerEmulatorVideoStreamHandlers(): void { ipcMain.handle('emulator:videoStreamStop', (event, args: { streamId: string }) => { stopSubscription(args.streamId, event.sender) }) + ipcMain.on( + 'emulator:videoStreamFrameAck', + (event, args: { streamId: string; deliveryToken: string; deliveryId: number }) => { + const subscription = + typeof args?.streamId === 'string' ? subscriptions.get(args.streamId) : undefined + if ( + subscription?.owner === event.sender && + args.deliveryToken === subscription.deliveryToken && + Number.isSafeInteger(args.deliveryId) && + args.deliveryId > 0 + ) { + subscription.delivery.acknowledge(args.deliveryId) + } + } + ) } diff --git a/src/main/ipc/export.ts b/src/main/ipc/export.ts index 667d3529287..83eec23bcf3 100644 --- a/src/main/ipc/export.ts +++ b/src/main/ipc/export.ts @@ -1,6 +1,7 @@ import { BrowserWindow, dialog, ipcMain } from 'electron' import { writeFile } from 'node:fs/promises' import { ExportTimeoutError, htmlToPdf } from '../lib/html-to-pdf' +import { assertHtmlToPdfInputWithinMemoryLimit } from '../lib/html-to-pdf-memory-limit' export type ExportHtmlToPdfArgs = { html: string @@ -17,11 +18,12 @@ export function registerExportHandlers(): void { 'export:html-to-pdf', async (event, args: ExportHtmlToPdfArgs): Promise => { const { html, title } = args - if (!html.trim()) { - return { success: false, error: 'No content to export' } - } try { + assertHtmlToPdfInputWithinMemoryLimit(html) + if (!html.trim()) { + return { success: false, error: 'No content to export' } + } const pdfBuffer = await htmlToPdf(html) // Why: sanitize to keep the suggested filename legal on every platform. diff --git a/src/main/ipc/filesystem-directory-reader.test.ts b/src/main/ipc/filesystem-directory-reader.test.ts new file mode 100644 index 00000000000..833e572a33d --- /dev/null +++ b/src/main/ipc/filesystem-directory-reader.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { FILESYSTEM_DIRECTORY_LIMIT_MESSAGE } from '../../shared/filesystem-directory-listing-limit' +import { collectLocalFilesystemDirectoryEntries } from './filesystem-directory-reader' + +type TestEntryOptions = { + name: string + directory?: boolean + symlink?: boolean + onClassify?: () => void +} + +function entry(options: TestEntryOptions) { + return { + name: options.name, + isDirectory: () => { + options.onClassify?.() + return options.directory ?? false + }, + isSymbolicLink: () => options.symlink ?? false + } +} + +describe('local filesystem directory reader', () => { + it('preserves directory-first sorting and keeps symlinks file-like', async () => { + const source = [ + entry({ name: 'z.txt' }), + entry({ name: 'linked-dir', directory: true, symlink: true }), + entry({ name: 'beta', directory: true }), + entry({ name: 'alpha', directory: true }) + ] + + await expect(collectLocalFilesystemDirectoryEntries(source)).resolves.toEqual([ + { name: 'alpha', isDirectory: true, isSymlink: false }, + { name: 'beta', isDirectory: true, isSymlink: false }, + { name: 'linked-dir', isDirectory: false, isSymlink: true }, + { name: 'z.txt', isDirectory: false, isSymlink: false } + ]) + }) + + it('stops before classifying or retaining the first over-limit entry', async () => { + let enumerated = 0 + let overLimitClassified = false + const source = { + async *[Symbol.asyncIterator]() { + for (const value of [ + entry({ name: 'one' }), + entry({ name: 'two' }), + entry({ name: 'three', onClassify: () => (overLimitClassified = true) }) + ]) { + enumerated += 1 + yield value + } + } + } + + await expect( + collectLocalFilesystemDirectoryEntries(source, { + maxEntries: 2, + maxRetainedBytes: 1024 + }) + ).rejects.toThrow(FILESYSTEM_DIRECTORY_LIMIT_MESSAGE) + expect(enumerated).toBe(3) + expect(overLimitClassified).toBe(false) + }) +}) diff --git a/src/main/ipc/filesystem-directory-reader.ts b/src/main/ipc/filesystem-directory-reader.ts new file mode 100644 index 00000000000..3977f1accba --- /dev/null +++ b/src/main/ipc/filesystem-directory-reader.ts @@ -0,0 +1,41 @@ +import { opendir } from 'node:fs/promises' +import type { DirEntry } from '../../shared/types' +import { + createFilesystemDirectoryLimitState, + trackFilesystemDirectoryEntry, + type FilesystemDirectoryListingLimits +} from '../../shared/filesystem-directory-listing-limit' + +type LocalDirectorySourceEntry = { + name: string + isDirectory(): boolean + isSymbolicLink(): boolean +} + +export async function readLocalFilesystemDirectory(dirPath: string): Promise { + return collectLocalFilesystemDirectoryEntries(await opendir(dirPath)) +} + +export async function collectLocalFilesystemDirectoryEntries( + directory: AsyncIterable | Iterable, + requestedLimits?: Partial +): Promise { + const entries: DirEntry[] = [] + const limit = createFilesystemDirectoryLimitState(requestedLimits) + for await (const entry of directory) { + trackFilesystemDirectoryEntry(limit, entry) + const isSymlink = entry.isSymbolicLink() + entries.push({ + name: entry.name, + // Why: avoid probing macOS TCC-protected symlink targets during listing. + isDirectory: !isSymlink && entry.isDirectory(), + isSymlink + }) + } + return entries.sort((left, right) => { + if (left.isDirectory !== right.isDirectory) { + return left.isDirectory ? -1 : 1 + } + return left.name.localeCompare(right.name) + }) +} diff --git a/src/main/ipc/filesystem-external-import-limits.test.ts b/src/main/ipc/filesystem-external-import-limits.test.ts new file mode 100644 index 00000000000..66079a8344d --- /dev/null +++ b/src/main/ipc/filesystem-external-import-limits.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { + admitExternalImportTreeEntry, + assertExternalImportSourcePaths, + assertExternalImportTreeDepth, + captureRuntimeUploadRetentionCheckpoint, + createExternalImportTreeBudget, + createRuntimeUploadRetentionBudget, + EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES, + EXTERNAL_IMPORT_MAX_SOURCE_PATH_BYTES, + EXTERNAL_IMPORT_MAX_SOURCE_PATHS, + EXTERNAL_IMPORT_MAX_TREE_DEPTH, + EXTERNAL_IMPORT_MAX_TREE_ENTRIES, + ExternalImportCapacityError, + REMOTE_IMPORT_MAX_FILE_BYTES, + REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES, + REMOTE_IMPORT_MAX_TOTAL_BYTES, + restoreRuntimeUploadRetentionCheckpoint, + retainRuntimeUploadFileBytes +} from './filesystem-external-import-limits' + +describe('external filesystem import limits', () => { + it('accepts the exact source-count boundary and rejects the next path', () => { + expect(() => + assertExternalImportSourcePaths( + Array.from({ length: EXTERNAL_IMPORT_MAX_SOURCE_PATHS }, () => '') + ) + ).not.toThrow() + expect(() => + assertExternalImportSourcePaths( + Array.from({ length: EXTERNAL_IMPORT_MAX_SOURCE_PATHS + 1 }, () => '') + ) + ).toThrow('External import accepts at most 256 source paths') + }) + + it('accepts the exact source-path byte boundary and rejects one byte more', () => { + expect(() => + assertExternalImportSourcePaths(['a'.repeat(EXTERNAL_IMPORT_MAX_SOURCE_PATH_BYTES)]) + ).not.toThrow() + expect(() => + assertExternalImportSourcePaths(['a'.repeat(EXTERNAL_IMPORT_MAX_SOURCE_PATH_BYTES + 1)]) + ).toThrow('External import source paths exceed 256 KiB') + }) + + it('accepts exactly 100,000 tree entries without retaining their paths', () => { + const budget = createExternalImportTreeBudget() + for (let index = 0; index < EXTERNAL_IMPORT_MAX_TREE_ENTRIES; index += 1) { + admitExternalImportTreeEntry(budget, 'entry', false) + } + + expect(budget).toEqual({ + entries: EXTERNAL_IMPORT_MAX_TREE_ENTRIES, + retainedPathBytes: 0 + }) + expect(() => admitExternalImportTreeEntry(budget, 'overflow', false)).toThrow( + 'External import tree exceeds 100,000 entries' + ) + expect(budget.entries).toBe(EXTERNAL_IMPORT_MAX_TREE_ENTRIES) + }) + + it('accepts the exact depth and per-path boundaries', () => { + const budget = createExternalImportTreeBudget() + expect(() => assertExternalImportTreeDepth(EXTERNAL_IMPORT_MAX_TREE_DEPTH)).not.toThrow() + expect(() => assertExternalImportTreeDepth(EXTERNAL_IMPORT_MAX_TREE_DEPTH + 1)).toThrow( + 'External import tree exceeds 256 nested directory levels' + ) + expect(() => + admitExternalImportTreeEntry( + budget, + 'a'.repeat(EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES), + false + ) + ).not.toThrow() + expect(() => + admitExternalImportTreeEntry( + budget, + 'a'.repeat(EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES + 1), + false + ) + ).toThrow('External import relative path exceeds 64 KiB') + }) + + it('accepts the exact aggregate retained-path boundary', () => { + const budget = createExternalImportTreeBudget() + const path = 'a'.repeat(EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES) + const pathCount = + REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES / (EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES * 2) + for (let index = 0; index < pathCount; index += 1) { + admitExternalImportTreeEntry(budget, path, true) + } + + expect(budget.retainedPathBytes).toBe(REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES) + expect(() => admitExternalImportTreeEntry(budget, 'b', true)).toThrow( + 'Remote import retained paths exceed 16 MiB' + ) + expect(budget.retainedPathBytes).toBe(REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES) + }) + + it('applies file and total byte limits across the full runtime-upload request', () => { + const budget = createRuntimeUploadRetentionBudget() + const exactFileCount = REMOTE_IMPORT_MAX_TOTAL_BYTES / REMOTE_IMPORT_MAX_FILE_BYTES + for (let index = 0; index < exactFileCount; index += 1) { + retainRuntimeUploadFileBytes(budget, `file-${index}`, REMOTE_IMPORT_MAX_FILE_BYTES) + } + + expect(budget.fileBytes).toBe(REMOTE_IMPORT_MAX_TOTAL_BYTES) + expect(() => retainRuntimeUploadFileBytes(budget, 'overflow', 1)).toThrow( + 'Remote import is too large' + ) + expect(budget.fileBytes).toBe(REMOTE_IMPORT_MAX_TOTAL_BYTES) + }) + + it('rejects one oversized file without consuming request capacity', () => { + const budget = createRuntimeUploadRetentionBudget() + + expect(() => + retainRuntimeUploadFileBytes(budget, 'large.bin', REMOTE_IMPORT_MAX_FILE_BYTES + 1) + ).toThrow("'large.bin' is too large for remote import") + expect(budget.fileBytes).toBe(0) + }) + + it('restores capacity reserved by a failed staged source', () => { + const budget = createRuntimeUploadRetentionBudget() + const checkpoint = captureRuntimeUploadRetentionCheckpoint(budget) + admitExternalImportTreeEntry(budget.tree, 'partial.txt', true) + retainRuntimeUploadFileBytes(budget, 'partial.txt', 1024) + + restoreRuntimeUploadRetentionCheckpoint(budget, checkpoint) + + expect(budget).toEqual({ + tree: { entries: 0, retainedPathBytes: 0 }, + fileBytes: 0 + }) + }) + + it('uses a typed error for every capacity rejection', () => { + const budget = createExternalImportTreeBudget() + + expect(() => + admitExternalImportTreeEntry( + budget, + 'a'.repeat(EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES + 1), + false + ) + ).toThrow(ExternalImportCapacityError) + }) +}) diff --git a/src/main/ipc/filesystem-external-import-limits.ts b/src/main/ipc/filesystem-external-import-limits.ts new file mode 100644 index 00000000000..542cb12e04c --- /dev/null +++ b/src/main/ipc/filesystem-external-import-limits.ts @@ -0,0 +1,157 @@ +import { + NATIVE_FILE_DROP_MAX_PATH_BYTES, + NATIVE_FILE_DROP_MAX_PATHS +} from '../../shared/native-file-drop' + +export const EXTERNAL_IMPORT_MAX_SOURCE_PATHS = NATIVE_FILE_DROP_MAX_PATHS +export const EXTERNAL_IMPORT_MAX_SOURCE_PATH_BYTES = NATIVE_FILE_DROP_MAX_PATH_BYTES +export const EXTERNAL_IMPORT_MAX_TREE_ENTRIES = 100_000 +export const EXTERNAL_IMPORT_MAX_TREE_DEPTH = 256 +export const EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES = 64 * 1024 +export const REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES = 16 * 1024 * 1024 +export const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 +export const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 + +export const EXTERNAL_IMPORT_TREE_ENTRY_LIMIT_MESSAGE = + 'External import tree exceeds 100,000 entries' +export const EXTERNAL_IMPORT_TREE_DEPTH_LIMIT_MESSAGE = + 'External import tree exceeds 256 nested directory levels' +export const EXTERNAL_IMPORT_RELATIVE_PATH_LIMIT_MESSAGE = + 'External import relative path exceeds 64 KiB' +export const REMOTE_IMPORT_RETAINED_PATH_LIMIT_MESSAGE = + 'Remote import retained paths exceed 16 MiB' + +export type ExternalImportTreeBudget = { + entries: number + retainedPathBytes: number +} + +export type RuntimeUploadRetentionBudget = { + tree: ExternalImportTreeBudget + fileBytes: number +} + +export type RuntimeUploadRetentionCheckpoint = { + entries: number + retainedPathBytes: number + fileBytes: number +} + +export class ExternalImportCapacityError extends Error { + constructor(message: string) { + super(message) + this.name = 'ExternalImportCapacityError' + } +} + +export function assertExternalImportSourcePaths( + sourcePaths: unknown +): asserts sourcePaths is readonly string[] { + if (!Array.isArray(sourcePaths)) { + throw new TypeError('External import source paths must be an array') + } + if (sourcePaths.length > EXTERNAL_IMPORT_MAX_SOURCE_PATHS) { + throw new ExternalImportCapacityError( + `External import accepts at most ${EXTERNAL_IMPORT_MAX_SOURCE_PATHS} source paths` + ) + } + + let pathBytes = 0 + for (const sourcePath of sourcePaths) { + if (typeof sourcePath !== 'string') { + throw new TypeError('External import source paths must be strings') + } + pathBytes += Buffer.byteLength(sourcePath, 'utf8') + if (pathBytes > EXTERNAL_IMPORT_MAX_SOURCE_PATH_BYTES) { + throw new ExternalImportCapacityError('External import source paths exceed 256 KiB') + } + } +} + +export function createExternalImportTreeBudget(): ExternalImportTreeBudget { + return { entries: 0, retainedPathBytes: 0 } +} + +export function assertExternalImportTreeDepth(depth: number): void { + if (depth > EXTERNAL_IMPORT_MAX_TREE_DEPTH) { + throw new ExternalImportCapacityError(EXTERNAL_IMPORT_TREE_DEPTH_LIMIT_MESSAGE) + } +} + +export function admitExternalImportTreeEntry( + budget: ExternalImportTreeBudget, + relativePath: string, + retainPath: boolean +): void { + if (budget.entries >= EXTERNAL_IMPORT_MAX_TREE_ENTRIES) { + throw new ExternalImportCapacityError(EXTERNAL_IMPORT_TREE_ENTRY_LIMIT_MESSAGE) + } + + const pathBytes = Buffer.byteLength(relativePath, 'utf8') + if (pathBytes > EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES) { + throw new ExternalImportCapacityError(EXTERNAL_IMPORT_RELATIVE_PATH_LIMIT_MESSAGE) + } + const retainedPathBytes = relativePath.length * 2 + if ( + retainPath && + retainedPathBytes > REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES - budget.retainedPathBytes + ) { + throw new ExternalImportCapacityError(REMOTE_IMPORT_RETAINED_PATH_LIMIT_MESSAGE) + } + + budget.entries += 1 + if (retainPath) { + budget.retainedPathBytes += retainedPathBytes + } +} + +export function createRuntimeUploadRetentionBudget(): RuntimeUploadRetentionBudget { + return { + tree: createExternalImportTreeBudget(), + fileBytes: 0 + } +} + +export function captureRuntimeUploadRetentionCheckpoint( + budget: RuntimeUploadRetentionBudget +): RuntimeUploadRetentionCheckpoint { + return { + entries: budget.tree.entries, + retainedPathBytes: budget.tree.retainedPathBytes, + fileBytes: budget.fileBytes + } +} + +export function restoreRuntimeUploadRetentionCheckpoint( + budget: RuntimeUploadRetentionBudget, + checkpoint: RuntimeUploadRetentionCheckpoint +): void { + budget.tree.entries = checkpoint.entries + budget.tree.retainedPathBytes = checkpoint.retainedPathBytes + budget.fileBytes = checkpoint.fileBytes +} + +export function assertRuntimeUploadFileFits( + budget: RuntimeUploadRetentionBudget, + relativePath: string, + fileBytes: number +): void { + if (!Number.isSafeInteger(fileBytes) || fileBytes < 0) { + throw new Error(`Could not safely measure '${relativePath}' for remote import`) + } + if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + throw new ExternalImportCapacityError(`'${relativePath}' is too large for remote import`) + } + if (fileBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES - budget.fileBytes) { + throw new ExternalImportCapacityError('Remote import is too large') + } +} + +export function retainRuntimeUploadFileBytes( + budget: RuntimeUploadRetentionBudget, + relativePath: string, + fileBytes: number +): void { + assertRuntimeUploadFileFits(budget, relativePath, fileBytes) + budget.fileBytes += fileBytes +} diff --git a/src/main/ipc/filesystem-import-ssh-directory.ts b/src/main/ipc/filesystem-import-ssh-directory.ts index d4dc794a7cc..f38cf4d43d6 100644 --- a/src/main/ipc/filesystem-import-ssh-directory.ts +++ b/src/main/ipc/filesystem-import-ssh-directory.ts @@ -1,7 +1,13 @@ -import { lstat, readdir, realpath } from 'node:fs/promises' +import { lstat, opendir, realpath } from 'node:fs/promises' import { isAbsolute, join, relative, sep } from 'node:path' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' +import { + admitExternalImportTreeEntry, + assertExternalImportTreeDepth, + createExternalImportTreeBudget, + type ExternalImportTreeBudget +} from './filesystem-external-import-limits' export async function captureLocalUploadRoot( sourcePath: string, @@ -23,18 +29,47 @@ export async function preScanSshImportDirectory( dirPath: string, remotePathFlavor: RemotePathFlavor ): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - for (const entry of entries) { - assertSafeRemotePathSegment(entry.name, remotePathFlavor) - if (entry.isSymbolicLink()) { - return true - } - if (entry.isDirectory()) { - const childPath = join(dirPath, entry.name) - if (await preScanSshImportDirectory(childPath, remotePathFlavor)) { + return preScanSshImportDirectoryWithinBudget( + dirPath, + remotePathFlavor, + createExternalImportTreeBudget(), + '', + 0 + ) +} + +async function preScanSshImportDirectoryWithinBudget( + dirPath: string, + remotePathFlavor: RemotePathFlavor, + budget: ExternalImportTreeBudget, + relativeDir: string, + depth: number +): Promise { + assertExternalImportTreeDepth(depth) + const directory = await opendir(dirPath) + try { + for await (const entry of directory) { + assertSafeRemotePathSegment(entry.name, remotePathFlavor) + const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name + admitExternalImportTreeEntry(budget, relativePath, false) + if (entry.isSymbolicLink()) { + return true + } + if ( + entry.isDirectory() && + (await preScanSshImportDirectoryWithinBudget( + join(dirPath, entry.name), + remotePathFlavor, + budget, + relativePath, + depth + 1 + )) + ) { return true } } + } finally { + await directory.close().catch(() => undefined) } return false } @@ -48,37 +83,72 @@ export async function uploadSshImportDirectory( remotePathFlavor: RemotePathFlavor, assertCurrent?: () => void ): Promise { + await uploadSshImportDirectoryWithinBudget( + provider, + uploadSession, + localDir, + remoteDir, + rootRealPath, + remotePathFlavor, + createExternalImportTreeBudget(), + '', + 0, + assertCurrent + ) +} + +async function uploadSshImportDirectoryWithinBudget( + provider: IFilesystemProvider, + uploadSession: FileUploadSession, + localDir: string, + remoteDir: string, + rootRealPath: string, + remotePathFlavor: RemotePathFlavor, + budget: ExternalImportTreeBudget, + relativeDir: string, + depth: number, + assertCurrent?: () => void +): Promise { + assertExternalImportTreeDepth(depth) await assertLocalUploadPathInsideRoot(rootRealPath, localDir) - const entries = await readdir(localDir, { withFileTypes: true }) - for (const entry of entries) { - assertSafeRemotePathSegment(entry.name, remotePathFlavor) - const localPath = join(localDir, entry.name) - const remotePath = `${remoteDir}/${entry.name}` - await assertLocalUploadPathInsideRoot(rootRealPath, localPath) - const statResult = await lstat(localPath) + const directory = await opendir(localDir) + try { + for await (const entry of directory) { + assertSafeRemotePathSegment(entry.name, remotePathFlavor) + const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name + admitExternalImportTreeEntry(budget, relativePath, false) + const localPath = join(localDir, entry.name) + const remotePath = `${remoteDir}/${entry.name}` + await assertLocalUploadPathInsideRoot(rootRealPath, localPath) + const statResult = await lstat(localPath) - // Why: skip symlinks and special files even after the up-front pre-scan; - // this closes the TOCTOU gap if one is created during upload. - if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { - continue - } + // Why: the up-front scan cannot prevent a source swap during upload. + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { + continue + } - if (statResult.isDirectory()) { + if (statResult.isDirectory()) { + assertCurrent?.() + await provider.createDirNoClobber(remotePath) + await uploadSshImportDirectoryWithinBudget( + provider, + uploadSession, + localPath, + remotePath, + rootRealPath, + remotePathFlavor, + budget, + relativePath, + depth + 1, + assertCurrent + ) + continue + } assertCurrent?.() - await provider.createDirNoClobber(remotePath) - await uploadSshImportDirectory( - provider, - uploadSession, - localPath, - remotePath, - rootRealPath, - remotePathFlavor, - assertCurrent - ) - continue + await uploadSession.uploadFile(localPath, remotePath, { exclusive: true }) } - assertCurrent?.() - await uploadSession.uploadFile(localPath, remotePath, { exclusive: true }) + } finally { + await directory.close().catch(() => undefined) } } diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts index 2c36fd9f2bb..95b5c4f0cdd 100644 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts @@ -9,6 +9,7 @@ const { mkdirMock, realpathMock, copyFileMock, + opendirMock, readdirMock, getConnMgrMock } = vi.hoisted(() => ({ @@ -17,6 +18,7 @@ const { mkdirMock: vi.fn(), realpathMock: vi.fn(), copyFileMock: vi.fn(), + opendirMock: vi.fn(), readdirMock: vi.fn(), getConnMgrMock: vi.fn() })) @@ -29,14 +31,15 @@ vi.mock('fs/promises', () => ({ writeFile: vi.fn(), realpath: realpathMock, copyFile: copyFileMock, - readdir: readdirMock + opendir: opendirMock })) vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { advanceSshConnectionGeneration, - resetSshConnectionGenerations + resetSshConnectionGenerations, + setSshConnectionGeneration } from '../ssh/ssh-connection-generation' import { registerSshFilesystemProvider, @@ -82,6 +85,7 @@ function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { } describe('fs:importExternalPaths — SSH operations', () => { + const sessionCounterStride = 2 ** 13 const destDir = '/home/user/project/src' const connId = 'ssh-conn-1' let provider: IFilesystemProvider @@ -130,12 +134,15 @@ describe('fs:importExternalPaths — SSH operations', () => { beforeEach(() => { handlers.clear() + resetSshConnectionGenerations() + setSshConnectionGeneration(connId, 0) ;[ handleMock, lstatMock, mkdirMock, realpathMock, copyFileMock, + opendirMock, readdirMock, getConnMgrMock ].forEach((m) => m.mockReset()) @@ -145,6 +152,12 @@ describe('fs:importExternalPaths — SSH operations', () => { realpathMock.mockImplementation(async (p: string) => p) lstatMock.mockRejectedValue(enoent()) readdirMock.mockResolvedValue([]) + opendirMock.mockImplementation(async (directoryPath: string) => ({ + async *[Symbol.asyncIterator]() { + yield* await readdirMock(directoryPath, { withFileTypes: true }) + }, + close: vi.fn().mockResolvedValue(undefined) + })) getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) uploadSession = { uploadFile: vi.fn().mockResolvedValue(undefined), @@ -162,6 +175,7 @@ describe('fs:importExternalPaths — SSH operations', () => { it('rejects a staged upload when a restarted HUB reaches the same target counter', async () => { resetSshConnectionGenerations(71) + setSshConnectionGeneration(connId, 71 * sessionCounterStride) const stagedGeneration = advanceSshConnectionGeneration(connId) const sourcePath = path.resolve('/tmp/dropped/restart.txt') lstatMock.mockImplementation(async (candidate: string) => { @@ -169,6 +183,7 @@ describe('fs:importExternalPaths — SSH operations', () => { throw enoent() } resetSshConnectionGenerations(72) + setSshConnectionGeneration(connId, 72 * sessionCounterStride) advanceSshConnectionGeneration(connId) return { size: 12, diff --git a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts index ec01175ad35..8d1e5de545b 100644 --- a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts +++ b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts @@ -2,17 +2,19 @@ import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -const { getConnMgrMock, lstatMock, providerMock, readdirMock, realpathMock } = vi.hoisted(() => ({ - getConnMgrMock: vi.fn(), - lstatMock: vi.fn(), - providerMock: vi.fn(), - readdirMock: vi.fn(), - realpathMock: vi.fn() -})) +const { getConnMgrMock, lstatMock, opendirMock, providerMock, readdirMock, realpathMock } = + vi.hoisted(() => ({ + getConnMgrMock: vi.fn(), + lstatMock: vi.fn(), + opendirMock: vi.fn(), + providerMock: vi.fn(), + readdirMock: vi.fn(), + realpathMock: vi.fn() + })) vi.mock('node:fs/promises', () => ({ lstat: lstatMock, - readdir: readdirMock, + opendir: opendirMock, realpath: realpathMock })) vi.mock('./filesystem-auth', () => ({ @@ -25,6 +27,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ })) import { importExternalPathsSsh } from './filesystem-import-ssh' +import { EXTERNAL_IMPORT_MAX_TREE_ENTRIES } from './filesystem-external-import-limits' function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { const missing = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -54,6 +57,12 @@ describe('SSH import remote path safety', () => { provider = createProvider(uploadSession) providerMock.mockReturnValue(provider) readdirMock.mockResolvedValue([]) + opendirMock.mockImplementation(async (directoryPath: string) => ({ + async *[Symbol.asyncIterator]() { + yield* await readdirMock(directoryPath, { withFileTypes: true }) + }, + close: vi.fn().mockResolvedValue(undefined) + })) realpathMock.mockImplementation(async (value: string) => value) }) @@ -189,6 +198,47 @@ describe('SSH import remote path safety', () => { } }) + it('rejects an oversized directory before creating the remote root', async () => { + const sourcePath = path.resolve('/tmp/generated') + let yieldedEntries = 0 + let iteratorClosed = false + lstatMock.mockResolvedValue({ + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => false + }) + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + try { + while (yieldedEntries <= EXTERNAL_IMPORT_MAX_TREE_ENTRIES) { + const index = yieldedEntries + yieldedEntries += 1 + yield { + name: `entry-${index}`, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + } finally { + iteratorClosed = true + } + }, + close: vi.fn().mockResolvedValue(undefined) + }) + + const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) + + expect(results[0]).toMatchObject({ + status: 'failed', + reason: 'External import tree exceeds 100,000 entries' + }) + expect(yieldedEntries).toBe(EXTERNAL_IMPORT_MAX_TREE_ENTRIES + 1) + expect(iteratorClosed).toBe(true) + expect(provider.createDirNoClobber).not.toHaveBeenCalled() + expect(uploadSession.uploadFile).not.toHaveBeenCalled() + }) + it('skips special entries without failing the directory import', async () => { const sourcePath = path.resolve('/tmp/assets') const regularPath = path.join(sourcePath, 'report.txt') diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index 6a5d502b14c..db00cbd5df4 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -37,6 +37,7 @@ vi.mock('fs/promises', () => ({ copyFile: copyFileMock, open: openMock, readdir: readdirMock, + opendir: vi.fn(), unlink: unlinkMock, rm: vi.fn() })) @@ -47,7 +48,10 @@ import { registerSshFilesystemProvider, unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' +import { + resetSshConnectionGenerations, + setSshConnectionGeneration +} from '../ssh/ssh-connection-generation' const store = { getRepos: () => [ @@ -130,6 +134,7 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { beforeEach(() => { handlers.clear() resetSshConnectionGenerations() + setSshConnectionGeneration(connId, 0) ;[ handleMock, lstatMock, diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index fcd004dcd06..79180a1cfb3 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -11,7 +11,7 @@ const { realpathMock, copyFileMock, openMock, - readFileMock, + opendirMock, readdirMock, rmMock, unlinkMock @@ -22,7 +22,7 @@ const { realpathMock: vi.fn(), copyFileMock: vi.fn(), openMock: vi.fn(), - readFileMock: vi.fn(), + opendirMock: vi.fn(), readdirMock: vi.fn(), rmMock: vi.fn(), unlinkMock: vi.fn() @@ -40,13 +40,17 @@ vi.mock('fs/promises', () => ({ writeFile: vi.fn(), realpath: realpathMock, copyFile: copyFileMock, - readFile: readFileMock, - readdir: readdirMock, + opendir: opendirMock, rm: rmMock, unlink: unlinkMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { + EXTERNAL_IMPORT_MAX_SOURCE_PATHS, + EXTERNAL_IMPORT_MAX_TREE_ENTRIES, + REMOTE_IMPORT_MAX_FILE_BYTES +} from './filesystem-external-import-limits' const REPO_PATH = path.resolve('/workspace/repo') const WORKSPACE_DIR = path.resolve('/workspace') @@ -62,6 +66,24 @@ function enoent(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } +function streamDirectoryEntries(entries: unknown[]) { + return { + async *[Symbol.asyncIterator]() { + yield* entries + } + } +} + +function createFileHandleRead(content: Buffer) { + return vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min(length, Math.max(0, content.byteLength - position)) + if (bytesRead > 0) { + content.copy(buffer, offset, position, position + bytesRead) + } + return { buffer, bytesRead } + }) +} + describe('fs:importExternalPaths', () => { const destDir = path.resolve('/workspace/repo/src') @@ -158,7 +180,7 @@ describe('fs:importExternalPaths', () => { realpathMock.mockReset() copyFileMock.mockReset() openMock.mockReset() - readFileMock.mockReset() + opendirMock.mockReset() readdirMock.mockReset() rmMock.mockReset() unlinkMock.mockReset() @@ -172,8 +194,10 @@ describe('fs:importExternalPaths', () => { mkdirMock.mockResolvedValue(undefined) copyFileMock.mockResolvedValue(undefined) mockLocalCopyOpenSuccess() - readFileMock.mockResolvedValue(Buffer.from('file-content')) readdirMock.mockResolvedValue([]) + opendirMock.mockImplementation(async (dirPath: string) => + streamDirectoryEntries(await readdirMock(dirPath)) + ) rmMock.mockResolvedValue(undefined) unlinkMock.mockResolvedValue(undefined) @@ -384,6 +408,67 @@ describe('fs:importExternalPaths', () => { expect(copyFileMock).not.toHaveBeenCalled() }) + it('stops streaming an oversized local tree before creating its destination', async () => { + const sourcePath = '/tmp/dropped/generated' + const resolvedSource = path.resolve(sourcePath) + let iteratorClosed = false + let yieldedEntries = 0 + lstatMock.mockImplementation(async (candidatePath: string) => { + if (candidatePath === resolvedSource) { + return { + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => false + } + } + throw enoent() + }) + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + try { + while (yieldedEntries <= EXTERNAL_IMPORT_MAX_TREE_ENTRIES) { + const index = yieldedEntries + yieldedEntries += 1 + yield { + name: `entry-${index}`, + isDirectory: () => false, + isSymbolicLink: () => false, + isFile: () => true + } + } + } finally { + iteratorClosed = true + } + } + }) + + const result = (await handlers.get('fs:importExternalPaths')!(null, { + sourcePaths: [sourcePath], + destDir + })) as { results: { status: string; reason?: string }[] } + + expect(result.results[0]).toMatchObject({ + status: 'failed', + reason: 'External import tree exceeds 100,000 entries' + }) + expect(yieldedEntries).toBe(EXTERNAL_IMPORT_MAX_TREE_ENTRIES + 1) + expect(iteratorClosed).toBe(true) + expect(mkdirMock).not.toHaveBeenCalled() + }) + + it('rejects oversized source batches before touching the filesystem', async () => { + const sourcePaths = Array.from( + { length: EXTERNAL_IMPORT_MAX_SOURCE_PATHS + 1 }, + (_, index) => `/tmp/dropped/${index}` + ) + + await expect( + handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { sourcePaths }) + ).rejects.toThrow('External import accepts at most 256 source paths') + expect(lstatMock).not.toHaveBeenCalled() + expect(openMock).not.toHaveBeenCalled() + }) + it('fails and removes output if a local directory entry becomes a symlink after pre-scan', async () => { const sourcePath = '/tmp/dropped/mixeddir' const resolvedSource = path.resolve(sourcePath) @@ -481,7 +566,7 @@ describe('fs:importExternalPaths', () => { lstatMock.mockImplementation(async (p: string) => { if (p === resolvedPath) { return { - size: 4, + size: 3, ino: 1, dev: 1, isFile: () => true, @@ -492,15 +577,15 @@ describe('fs:importExternalPaths', () => { throw enoent() }) const closeMock = vi.fn().mockResolvedValue(undefined) - const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png')) + const readFileHandleMock = createFileHandleRead(Buffer.from('png')) openMock.mockResolvedValue({ stat: vi.fn().mockResolvedValue({ - size: 4, + size: 3, ino: 1, dev: 1, isFile: () => true }), - readFile: readFileHandleMock, + read: readFileHandleMock, close: closeMock }) @@ -580,7 +665,7 @@ describe('fs:importExternalPaths', () => { dev: 1, isFile: () => true }), - readFile: vi.fn().mockResolvedValue(Buffer.from('icon')), + read: createFileHandleRead(Buffer.from('icon')), close: vi.fn().mockResolvedValue(undefined) }) @@ -637,16 +722,10 @@ describe('fs:importExternalPaths', () => { expect(openMock).not.toHaveBeenCalled() }) - it('checks runtime upload directory byte budget before reading a file that exceeds the total cap', async () => { + it('checks the runtime upload file budget before opening an oversized entry', async () => { const sourcePath = '/tmp/dropped/project' const resolvedPath = path.resolve(sourcePath) - const filePaths = ['one.bin', 'two.bin', 'three.bin', 'four.bin', 'overflow.bin'].map((name) => - path.join(resolvedPath, name) - ) - const mib = 1024 * 1024 - const regularSize = 25 * mib - const overflowSize = 1 * mib - const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk')) + const oversizedPath = path.join(resolvedPath, 'oversized.bin') lstatMock.mockImplementation(async (p: string) => { if (p === resolvedPath) { @@ -659,12 +738,10 @@ describe('fs:importExternalPaths', () => { isSymbolicLink: () => false } } - const fileIndex = filePaths.indexOf(p) - if (fileIndex !== -1) { - const size = fileIndex === filePaths.length - 1 ? overflowSize : regularSize + if (p === oversizedPath) { return { - size, - ino: fileIndex + 2, + size: REMOTE_IMPORT_MAX_FILE_BYTES + 1, + ino: 2, dev: 1, isFile: () => true, isDirectory: () => false, @@ -673,30 +750,14 @@ describe('fs:importExternalPaths', () => { } throw enoent() }) - readdirMock.mockResolvedValue( - filePaths.map((filePath) => ({ - name: path.basename(filePath), + readdirMock.mockResolvedValue([ + { + name: path.basename(oversizedPath), isDirectory: () => false, isSymbolicLink: () => false, isFile: () => true - })) - ) - openMock.mockImplementation(async (p: string) => { - const fileIndex = filePaths.indexOf(p) - if (fileIndex >= 0 && fileIndex < filePaths.length - 1) { - return { - stat: vi.fn().mockResolvedValue({ - size: regularSize, - ino: fileIndex + 2, - dev: 1, - isFile: () => true - }), - readFile: readFileMock, - close: vi.fn().mockResolvedValue(undefined) - } } - throw new Error(`unexpected open: ${p}`) - }) + ]) const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { sourcePaths: [sourcePath] @@ -704,10 +765,9 @@ describe('fs:importExternalPaths', () => { expect(result.sources[0]).toMatchObject({ status: 'failed', - reason: 'Remote import is too large' + reason: "'oversized.bin' is too large for remote import" }) - expect(readFileMock).toHaveBeenCalledTimes(4) - expect(openMock).not.toHaveBeenCalledWith(filePaths.at(-1), expect.anything()) + expect(openMock).not.toHaveBeenCalled() }) it('fails runtime upload staging when a file changes between lstat and open', async () => { @@ -726,7 +786,7 @@ describe('fs:importExternalPaths', () => { } throw enoent() }) - const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png')) + const readFileHandleMock = createFileHandleRead(Buffer.from('png')) openMock.mockResolvedValue({ stat: vi.fn().mockResolvedValue({ size: 4, @@ -734,7 +794,7 @@ describe('fs:importExternalPaths', () => { dev: 1, isFile: () => true }), - readFile: readFileHandleMock, + read: readFileHandleMock, close: vi.fn().mockResolvedValue(undefined) }) diff --git a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts index 5d5cf163746..ee0eeb3a6df 100644 --- a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts +++ b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts @@ -62,12 +62,13 @@ describe('main Quick Open git directory expansion', () => { const promise = listFilesWithGit(root, [], {}) revParse.emit('close', 0, null) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(2)) ;(primary.stdout as unknown as EventEmitter).emit( 'data', `100644 ${SHA1} 0\tsrc/index.ts\0scratch/\0` ) primary.emit('close', 0, null) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) ;(ignored.stdout as unknown as EventEmitter).emit('data', 'dist/\0') ignored.emit('close', 0, null) @@ -79,7 +80,7 @@ describe('main Quick Open git directory expansion', () => { expect(gitSpawnMock.mock.calls[2][0]).toContain('--directory') }) - it('cancels both local Git passes when Quick Open abandons the request', async () => { + it('cancels the active local Git pass when Quick Open abandons the request', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-main-git-cancel-')) tempDirs.push(root) const revParse = createMockProcess() @@ -93,11 +94,12 @@ describe('main Quick Open git directory expansion', () => { const controller = new AbortController() const promise = listFilesWithGit(root, [], {}, controller.signal) revParse.emit('close', 0, null) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(2)) controller.abort() await expect(promise).rejects.toSatisfy(isFileListingCancellation) expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).toHaveBeenCalled() + expect(ignored.kill).not.toHaveBeenCalled() + expect(gitSpawnMock).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index ff390d13caf..f86d13e879a 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -12,6 +12,13 @@ import { parseQuickOpenGitLsFilesEntry } from '../../shared/quick-open-readdir-walk' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' +import { + createQuickOpenListingBudget, + QUICK_OPEN_LISTING_MAX_PATH_BYTES, + QuickOpenSubprocessPathAccumulator, + resolveQuickOpenResultLimit, + retainQuickOpenPath +} from '../../shared/quick-open-listing-limits' /** * Fallback file lister using git ls-files. Used when rg is not available. @@ -81,6 +88,13 @@ export async function listFilesWithGit( signal?: AbortSignal, maxResults?: number ): Promise { + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } + const resultLimit = resolveQuickOpenResultLimit(maxResults) + if (resultLimit === 0) { + return [] + } const isGitWorkTree = await isInsideGitWorkTree(rootPath, localGitOptions, signal) if (signal?.aborted) { throw fileListingCancellationError(signal) @@ -89,7 +103,7 @@ export async function listFilesWithGit( return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, budget: createQuickOpenReaddirBudget(), - maxResults, + maxResults: resultLimit, signal }) } @@ -97,6 +111,7 @@ export async function listFilesWithGit( const gitPaths = new Set() const directoryPaths = new Set() const directFileCandidates = new Set() + const listingBudget = createQuickOpenListingBudget() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ChildProcess @@ -107,7 +122,7 @@ export async function listFilesWithGit( const runGitLsFiles = (args: string[]): Promise => { return new Promise((resolve, reject) => { - let buf = '' + const paths = new QuickOpenSubprocessPathAccumulator(0) let done = false const processPath = (path: string): boolean => { @@ -115,27 +130,23 @@ export async function listFilesWithGit( return false } if (path.endsWith('/')) { - directoryPaths.add(path) + retainQuickOpenPath(directoryPaths, path, listingBudget) } else { - gitPaths.add(path) - if (maxResults !== undefined) { - // Why: this duplicate classification exists only to stop bounded - // scans; unbounded scans must not retain a second repo-sized set. - const parsed = parseQuickOpenGitLsFilesEntry(path) - const relPath = parsed.path.replace(/\/+$/, '') - if ( - !parsed.isGitlink && - !parsed.isUntrackedDir && - shouldIncludeQuickOpenPath(relPath) && - !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) - ) { - directFileCandidates.add(relPath) - } + retainQuickOpenPath(gitPaths, path, listingBudget) + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + retainQuickOpenPath(directFileCandidates, relPath, listingBudget) } } // Why: collapsed directories and gitlinks may be discarded during the // later filesystem classification, so they cannot consume the stop cap. - return maxResults !== undefined && directFileCandidates.size >= maxResults + return directFileCandidates.size >= resultLimit } // Why: git ls-files outputs paths relative to cwd, so we set cwd to @@ -160,7 +171,7 @@ export async function listFilesWithGit( return } done = true - buf = '' + paths.clear() cleanup() reject(err) } @@ -169,6 +180,7 @@ export async function listFilesWithGit( return } done = true + paths.clear() cleanup() resolve() } @@ -178,20 +190,23 @@ export async function listFilesWithGit( reject: rejectPass, resolve: resolvePass }) - const handleStdoutData = (chunk: string): void => { - buf += chunk - let start = 0 - let nulIdx = buf.indexOf('\0', start) - while (nulIdx !== -1) { - if (processPath(buf.substring(start, nulIdx))) { - buf = '' + const failForOutput = (error: unknown): void => { + child.kill() + rejectPass(error instanceof Error ? error : new Error(String(error))) + } + const handleStdoutData = (chunk: Buffer | string): void => { + try { + const outcome = paths.push(chunk, (path) => !processPath(path)) + if (outcome === 'stopped') { finishAtLimit() - return + } else if (outcome === 'path-too-large') { + failForOutput( + new Error(`Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes`) + ) } - start = nulIdx + 1 - nulIdx = buf.indexOf('\0', start) + } catch (error) { + failForOutput(error) } - buf = start < buf.length ? buf.substring(start) : '' } const handleStderrData = (): void => { /* drain */ @@ -207,9 +222,14 @@ export async function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - if (buf && processPath(buf)) { - buf = '' - finishAtLimit() + try { + const trailingPath = paths.finish() + if (trailingPath && processPath(trailingPath)) { + finishAtLimit() + return + } + } catch (error) { + failForOutput(error) return } if (code === 0) { @@ -219,13 +239,12 @@ export async function listFilesWithGit( rejectPass(new Error(`git ls-files exited with code ${code}`)) } - child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) child.once('close', handleClose) timer = setTimeout(() => { - buf = '' + paths.clear() child.kill() rejectPass(new Error('git ls-files timed out')) }, 10000) @@ -269,15 +288,11 @@ export async function listFilesWithGit( console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err) } }) - if (maxResults === undefined) { - await Promise.all([runGitLsFiles(primary), runIgnoredPass()]) - } else { - // Why: give ordinary source files first claim on a bounded autocomplete - // inventory; a large ignored tree must not win a parallel-output race. - await runGitLsFiles(primary) - if (directFileCandidates.size < maxResults) { - await runIgnoredPass() - } + // Why: give ordinary source files first claim on a bounded autocomplete + // inventory; a large ignored tree must not win a parallel-output race. + await runGitLsFiles(primary) + if (directFileCandidates.size < resultLimit) { + await runIgnoredPass() } } catch (err) { killSurvivors() @@ -295,9 +310,9 @@ export async function listFilesWithGit( directoryPaths, excludePathPrefixes, signal, - maxResults + maxResults: resultLimit }) // Why: directory placeholders are expanded after Git exits; restore Git's // path order so empty queries and fuzzy-score ties remain stable. - return files.sort().slice(0, maxResults) + return files.sort().slice(0, resultLimit) } diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 946f8653dd6..1801b779ece 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -36,6 +36,11 @@ import { listQuickOpenFiles } from './filesystem-list-files' import { EventEmitter } from 'node:events' import type { Store } from '../persistence' import type { ChildProcess } from 'node:child_process' +import { + QUICK_OPEN_LISTING_MAX_PATH_BYTES, + QUICK_OPEN_LISTING_MAX_RESULTS +} from '../../shared/quick-open-listing-limits' +import { isFileListingCancellation } from '../../shared/file-listing-cancellation' const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -95,6 +100,88 @@ describe('filesystem-list-files', () => { expect(spawnMock).toHaveBeenCalledTimes(1) }) + it('bounds an omitted rg result limit and recovers on the next scan', async () => { + const primary = createMockProcess() + const ignored = createMockProcess() + spawnMock.mockImplementation((_cmd, args: string[]) => + isIgnoredRgPass(args) ? ignored : primary + ) + const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) + const paths = Array.from( + { length: QUICK_OPEN_LISTING_MAX_RESULTS + 1 }, + (_value, index) => `src/file-${index}.ts` + ) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + ;(primary.stdout as unknown as EventEmitter).emit('data', paths.join('\n')) + + const result = await promise + expect(result).toHaveLength(QUICK_OPEN_LISTING_MAX_RESULTS) + expect(result.at(-1)).toBe(`src/file-${QUICK_OPEN_LISTING_MAX_RESULTS - 1}.ts`) + expect(primary.kill).toHaveBeenCalled() + expect(ignored.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) + + const recoveryPrimary = createMockProcess() + const recoveryIgnored = createMockProcess() + spawnMock.mockImplementation((_cmd, args: string[]) => + isIgnoredRgPass(args) ? recoveryIgnored : recoveryPrimary + ) + const recovery = listQuickOpenFiles('/mock/root', {} as unknown as Store) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + ;(recoveryPrimary.stdout as unknown as EventEmitter).emit('data', 'src/recovered.ts\n') + recoveryPrimary.emit('close', 0, null) + queueMicrotask(() => recoveryIgnored.emit('close', 0, null)) + + await expect(recovery).resolves.toEqual(['src/recovered.ts']) + }) + + it('kills a local rg scan whose residual path exceeds the field limit', async () => { + const primary = createMockProcess() + const ignored = createMockProcess() + spawnMock.mockImplementation((_cmd, args: string[]) => + isIgnoredRgPass(args) ? ignored : primary + ) + const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + ;(primary.stdout as unknown as EventEmitter).emit( + 'data', + Buffer.alloc(QUICK_OPEN_LISTING_MAX_PATH_BYTES + 1, 0x61) + ) + + await expect(promise).rejects.toThrow( + `Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes` + ) + expect(primary.kill).toHaveBeenCalled() + expect(ignored.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) + }) + + it('kills the active local rg pass when the listing is cancelled', async () => { + const primary = createMockProcess() + const ignored = createMockProcess() + spawnMock.mockImplementation((_cmd, args: string[]) => + isIgnoredRgPass(args) ? ignored : primary + ) + const controller = new AbortController() + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + controller.signal + ) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + + controller.abort() + + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + expect(primary.kill).toHaveBeenCalled() + expect(ignored.kill).not.toHaveBeenCalled() + expect((primary.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(primary.listenerCount('close')).toBe(0) + }) + it('merges normal files and ignored files and filters correctly', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -119,12 +206,13 @@ describe('filesystem-list-files', () => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'file2.js\n') p1.emit('close', 0, null) - // Simulate stdout output for ignored files - ;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n') - ;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate - ;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n') - p2.emit('close', 0, null) + queueMicrotask(() => { + ;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n') + ;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') + ;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate + ;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n') + p2.emit('close', 0, null) + }) }, 10) const result = await promise @@ -156,7 +244,7 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') p1.emit('close', 0, null) - p2.emit('close', 0, null) + queueMicrotask(() => p2.emit('close', 0, null)) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -186,7 +274,7 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n') p1.emit('close', 0, null) - p2.emit('close', 0, null) + queueMicrotask(() => p2.emit('close', 0, null)) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -208,13 +296,12 @@ describe('filesystem-list-files', () => { setTimeout(() => { p1.emit('close', 2, null) - p2.emit('close', 0, null) }, 10) await expect(promise).rejects.toThrow('rg exited with code 2') }) - it('kills the sibling rg pass after one pass fails', async () => { + it('does not spawn the ignored rg pass after the primary pass fails', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -234,7 +321,8 @@ describe('filesystem-list-files', () => { }, 10) await expect(promise).rejects.toThrow('rg exited with code 2') - expect(p2.kill).toHaveBeenCalled() + expect(p2.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) }) it('accepts rg code 2 when rg emitted parseable paths first', async () => { @@ -254,7 +342,7 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') p1.emit('close', 2, null) - p2.emit('close', 0, null) + queueMicrotask(() => p2.emit('close', 0, null)) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -288,7 +376,8 @@ describe('filesystem-list-files', () => { await rejection expect(p1.kill).toHaveBeenCalled() - expect(p2.kill).toHaveBeenCalled() + expect(p2.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) expect((p1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((p1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(p1.listenerCount('error')).toBe(0) @@ -321,8 +410,7 @@ describe('filesystem-list-files', () => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n') p1.emit('close', 0, null) - // Empty ignored result - p2.emit('close', 0, null) + queueMicrotask(() => p2.emit('close', 0, null)) }, 10) const result = await promise @@ -371,9 +459,11 @@ describe('filesystem-list-files', () => { ) gitP1.emit('close', 0, null) - ;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\0') - ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') - gitP2.emit('close', 0, null) + queueMicrotask(() => { + ;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\0') + ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + gitP2.emit('close', 0, null) + }) }, 10) const result = await promise @@ -438,6 +528,37 @@ describe('filesystem-list-files', () => { expect(callIndex).toBe(1) }) + it('bounds the omitted local Git result limit before spawning the ignored pass', async () => { + checkRgAvailableMock.mockResolvedValue(false) + const revParseProc = createMockProcess() + const primary = createMockProcess() + const ignored = createMockProcess() + let gitPasses = 0 + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + gitPasses++ + return gitPasses === 1 ? primary : ignored + }) + const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) + const paths = Array.from( + { length: QUICK_OPEN_LISTING_MAX_RESULTS + 1 }, + (_value, index) => `src/file-${index}.ts` + ) + setTimeout(() => revParseProc.emit('close', 0, null), 0) + setTimeout( + () => (primary.stdout as unknown as EventEmitter).emit('data', paths.join('\0')), + 10 + ) + + const result = await promise + expect(result).toHaveLength(QUICK_OPEN_LISTING_MAX_RESULTS) + expect(primary.kill).toHaveBeenCalled() + expect(ignored.kill).not.toHaveBeenCalled() + expect(gitPasses).toBe(1) + }) + it('does not let a discarded Git directory placeholder consume the result budget', async () => { checkRgAvailableMock.mockResolvedValue(false) const revParseProc = createMockProcess() @@ -509,7 +630,7 @@ describe('filesystem-list-files', () => { ;(gitP1.stdout as unknown as EventEmitter).emit('data', `${staged('100644', 'valid.ts')}\0`) gitP1.emit('close', 0, null) - gitP2.emit('close', 0, null) + queueMicrotask(() => gitP2.emit('close', 0, null)) }, 10) const result = await promise @@ -556,7 +677,8 @@ describe('filesystem-list-files', () => { await rejection expect(gitP1.kill).toHaveBeenCalled() - expect(gitP2.kill).toHaveBeenCalled() + expect(gitP2.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) expect((gitP1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((gitP1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(gitP1.listenerCount('error')).toBe(0) @@ -604,6 +726,7 @@ describe('filesystem-list-files', () => { `${staged('100644', 'src/index.ts')}\0` ) gitP1.emit('close', 0, null) + await Promise.resolve() // Ignored entries streamed before the timeout are kept. ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') @@ -639,7 +762,7 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'file.ts\n') p1.emit('close', 0, null) - p2.emit('close', 0, null) + queueMicrotask(() => p2.emit('close', 0, null)) }, 10) const result = await promise diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 7edb688de72..49105dc9b40 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -16,6 +16,14 @@ import { } from '../../shared/quick-open-filter' import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk' import { buildInstallRgMessage } from '../../shared/quick-open-install-rg' +import { + createQuickOpenListingBudget, + QUICK_OPEN_LISTING_MAX_PATH_BYTES, + QuickOpenSubprocessPathAccumulator, + resolveQuickOpenResultLimit, + retainQuickOpenPath +} from '../../shared/quick-open-listing-limits' +import { fileListingCancellationError } from '../../shared/file-listing-cancellation' import { listFilesWithGit } from './filesystem-list-files-git-fallback' export async function listQuickOpenFiles( @@ -37,6 +45,13 @@ export async function listQuickOpenFiles( // every worktree instead of just the active one. The shared helper // normalizes, validates, and root-relativizes every input. const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, excludePaths) + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } + const resultLimit = resolveQuickOpenResultLimit(maxResults) + if (resultLimit === 0) { + return [] + } // Why: checking rg availability upfront avoids a race condition where // spawn('rg') emits 'close' before 'error' on some platforms, causing @@ -50,7 +65,7 @@ export async function listQuickOpenFiles( excludePathPrefixes, localGitOptions, signal, - maxResults + resultLimit ) } catch (err) { if (!isQuickOpenReaddirBudgetError(err)) { @@ -61,10 +76,11 @@ export async function listQuickOpenFiles( } const files = new Set() + const listingBudget = createQuickOpenListingBudget() const children: { child: ChildProcess isDone: () => boolean - finish: () => void + finish: (error?: Error) => void }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. @@ -82,8 +98,11 @@ export async function listQuickOpenFiles( }) const runRg = (args: string[]): Promise => { + if (signal?.aborted) { + return Promise.reject(fileListingCancellationError(signal)) + } return new Promise((resolve, reject) => { - let buf = '' + const paths = new QuickOpenSubprocessPathAccumulator(0x0a) let done = false let parseablePathCount = 0 @@ -106,11 +125,11 @@ export async function listQuickOpenFiles( if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { return false } - if (maxResults !== undefined && files.size >= maxResults) { + if (files.size >= resultLimit) { return true } - files.add(relPath) - return maxResults !== undefined && files.size >= maxResults + retainQuickOpenPath(files, relPath, listingBudget) + return files.size >= resultLimit } const child = wslAwareSpawn('rg', args, { @@ -119,20 +138,24 @@ export async function listQuickOpenFiles( stdio: ['ignore', 'pipe', 'pipe'] }) let timer: ReturnType - const handleStdoutData = (chunk: string): void => { - buf += chunk - let start = 0 - let newlineIdx = buf.indexOf('\n', start) - while (newlineIdx !== -1) { - if (processLine(buf.substring(start, newlineIdx))) { - buf = '' + const failForOutput = (error: unknown): void => { + paths.clear() + child.kill() + finish(error instanceof Error ? error : new Error(String(error))) + } + const handleStdoutData = (chunk: Buffer | string): void => { + try { + const outcome = paths.push(chunk, (path) => !processLine(path)) + if (outcome === 'stopped') { finishAtLimit() - return + } else if (outcome === 'path-too-large') { + failForOutput( + new Error(`Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes`) + ) } - start = newlineIdx + 1 - newlineIdx = buf.indexOf('\n', start) + } catch (error) { + failForOutput(error) } - buf = start < buf.length ? buf.substring(start) : '' } const handleStderrData = (): void => { /* drain */ @@ -140,7 +163,7 @@ export async function listQuickOpenFiles( const handleError = (): void => { // Why: treat spawn errors like an abnormal exit — discard residual // buffer so a truncated final byte sequence cannot leak as a path. - buf = '' + paths.clear() finish(new Error('rg failed to start')) } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { @@ -148,13 +171,18 @@ export async function listQuickOpenFiles( // Why: a signal exit means timeout/OOM/external kill. Returning the // already-streamed prefix would recreate the false-empty bug this // path is meant to avoid. - buf = '' + paths.clear() finish(new Error(`rg killed by ${signal}`)) return } - if (buf && processLine(buf)) { - buf = '' - finishAtLimit() + try { + const trailingPath = paths.finish() + if (trailingPath && processLine(trailingPath)) { + finishAtLimit() + return + } + } catch (error) { + failForOutput(error) return } if (code === 0 || code === 1) { @@ -188,7 +216,6 @@ export async function listQuickOpenFiles( children.push({ child, isDone: () => done, finish }) - child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) @@ -196,7 +223,7 @@ export async function listQuickOpenFiles( timer = setTimeout(() => { // Why: on timeout, the buffer is likely truncated mid-path. Discard // it so Quick Open never displays a malformed entry. - buf = '' + paths.clear() child.kill() finish(new Error('rg list timed out')) }, 10000) @@ -230,22 +257,33 @@ export async function listQuickOpenFiles( } } - try { - if (maxResults === undefined) { - await Promise.all([runRg(primary), runRg(ignoredPass)]) - } else { - // Why: ignored-file output can be much larger and faster than the primary - // pass; let source files claim the bounded autocomplete budget first. - await runRg(primary) - if (files.size < maxResults) { - await runRg(ignoredPass) + const onAbort = (): void => { + const error = fileListingCancellationError(signal) + for (const entry of children) { + if (entry.isDone()) { + continue } + entry.finish(error) + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + signal?.addEventListener('abort', onAbort, { once: true }) + try { + // Why: ignored-file output can be much larger and faster than the primary + // pass; let source files claim the bounded autocomplete budget first. + await runRg(primary) + if (files.size < resultLimit) { + await runRg(ignoredPass) } } catch (err) { killSurvivors() throw err + } finally { + signal?.removeEventListener('abort', onAbort) } - return Array.from(files).slice(0, maxResults) + return Array.from(files).slice(0, resultLimit) } function getQuickOpenRgOutputMode( diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index dcaeb74b624..6bc17af2af7 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -24,7 +24,7 @@ vi.mock('fs/promises', () => ({ writeFile: writeFileMock, realpath: realpathMock, copyFile: copyFileMock, - readdir: vi.fn() + opendir: vi.fn() })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' @@ -77,6 +77,7 @@ describe('registerFilesystemMutationHandlers', () => { writeFileMock.mockReset() realpathMock.mockReset() resetSshConnectionGenerations() + setSshConnectionGeneration('ssh-1', 0) handleMock.mockImplementation((channel: string, handler: never) => { handlers.set(channel, handler) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index e89ff761b63..80b30617691 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -7,7 +7,7 @@ import { lstat, mkdir, open, - readdir, + opendir, realpath, rename, rm, @@ -24,6 +24,19 @@ import { importExternalPathsSsh } from './filesystem-import-ssh' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import type { SshMutationExpectation } from '../../shared/ssh-types' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' +import { readStableRuntimeUploadFile } from './filesystem-runtime-upload-file-reader' +import { + admitExternalImportTreeEntry, + assertExternalImportSourcePaths, + assertExternalImportTreeDepth, + assertRuntimeUploadFileFits, + captureRuntimeUploadRetentionCheckpoint, + createExternalImportTreeBudget, + createRuntimeUploadRetentionBudget, + restoreRuntimeUploadRetentionCheckpoint, + retainRuntimeUploadFileBytes, + type RuntimeUploadRetentionBudget +} from './filesystem-external-import-limits' /** * Re-throw filesystem errors with user-friendly messages. @@ -201,6 +214,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedSshConnectionGeneration, args.expectedExecutionHostId ) + assertExternalImportSourcePaths(args.sourcePaths) if (args.connectionId) { return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, { ensureDir: args.ensureDir, @@ -242,8 +256,10 @@ export function registerFilesystemMutationHandlers(store: Store): void { args: { sourcePaths: string[] } ): Promise<{ sources: StagedExternalImportSource[] }> => { const sources: StagedExternalImportSource[] = [] + assertExternalImportSourcePaths(args.sourcePaths) + const retentionBudget = createRuntimeUploadRetentionBudget() for (const sourcePath of args.sourcePaths) { - sources.push(await stageOneSourceForRuntimeUpload(sourcePath)) + sources.push(await stageOneSourceForRuntimeUpload(sourcePath, retentionBudget)) } return { sources } } @@ -271,6 +287,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedSshConnectionGeneration, args.expectedExecutionHostId ) + assertExternalImportSourcePaths(args.paths) // Why: `== null` (not `!args.connectionId`) so an empty string is // treated as a renderer error, not silently routed to the local branch. if (args.connectionId == null) { @@ -362,9 +379,6 @@ export type StagedExternalImportEntry = | { relativePath: string; kind: 'directory' } | { relativePath: string; kind: 'file'; contentBase64: string } -const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 -const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 - class RuntimeUploadSymlinkError extends Error {} // ─── External Import Implementation ───────────────────────────────── @@ -425,9 +439,17 @@ async function importOneSource( // creating any destination files. This prevents partially imported // trees when a symlink is discovered halfway through recursive copy. if (isDir) { - const hasSymlink = await preScanForSymlinks(resolvedSource) - if (hasSymlink) { - return { sourcePath, status: 'skipped', reason: 'symlink' } + try { + const hasSymlink = await preScanForSymlinks(resolvedSource) + if (hasSymlink) { + return { sourcePath, status: 'skipped', reason: 'symlink' } + } + } catch (error) { + return { + sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + } } } @@ -462,7 +484,8 @@ async function importOneSource( } async function stageOneSourceForRuntimeUpload( - sourcePath: string + sourcePath: string, + retentionBudget: RuntimeUploadRetentionBudget ): Promise { const resolvedSource = resolve(sourcePath) @@ -498,10 +521,15 @@ async function stageOneSourceForRuntimeUpload( if (!sourceStat.isFile() && !sourceStat.isDirectory()) { return { sourcePath, status: 'skipped', reason: 'unsupported' } } + const checkpoint = captureRuntimeUploadRetentionCheckpoint(retentionBudget) try { - const entries = sourceStat.isDirectory() - ? await stageDirectoryEntries(resolvedSource) - : [(await stageFileEntry(resolvedSource, '')).entry] + let entries: StagedExternalImportEntry[] + if (sourceStat.isDirectory()) { + entries = await stageDirectoryEntries(resolvedSource, retentionBudget) + } else { + admitExternalImportTreeEntry(retentionBudget.tree, '', true) + entries = [await stageFileEntry(resolvedSource, '', { retentionBudget })] + } return { sourcePath, status: 'staged', @@ -510,6 +538,7 @@ async function stageOneSourceForRuntimeUpload( entries } } catch (error) { + restoreRuntimeUploadRetentionCheckpoint(retentionBudget, checkpoint) if (error instanceof RuntimeUploadSymlinkError) { return { sourcePath, status: 'skipped', reason: 'symlink' } } @@ -521,61 +550,61 @@ async function stageOneSourceForRuntimeUpload( } } -async function stageDirectoryEntries(rootPath: string): Promise { +async function stageDirectoryEntries( + rootPath: string, + retentionBudget: RuntimeUploadRetentionBudget +): Promise { + admitExternalImportTreeEntry(retentionBudget.tree, '', true) const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }] - let totalBytes = 0 const rootRealPath = await realpath(rootPath) - async function visit(dirPath: string): Promise { + async function visit(dirPath: string, depth: number): Promise { + assertExternalImportTreeDepth(depth) const dirStat = await lstat(dirPath) + const dirRelativePath = normalizeRelativeUploadPath(relative(rootPath, dirPath)) if (dirStat.isSymbolicLink()) { - throw new RuntimeUploadSymlinkError( - `Symlink not allowed in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'` - ) + throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${dirRelativePath}'`) } if (!dirStat.isDirectory()) { - throw new Error( - `Unsupported file type in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'` - ) + throw new Error(`Unsupported file type in '${dirRelativePath}'`) } - await assertRealPathInsideRoot( - rootRealPath, - dirPath, - normalizeRelativeUploadPath(relative(rootPath, dirPath)) - ) - const dirEntries = await readdir(dirPath, { withFileTypes: true }) - for (const entry of dirEntries) { + await assertRealPathInsideRoot(rootRealPath, dirPath, dirRelativePath) + const directory = await opendir(dirPath) + for await (const entry of directory) { const childPath = join(dirPath, entry.name) const childRelativePath = normalizeRelativeUploadPath(relative(rootPath, childPath)) if (entry.isSymbolicLink()) { throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${childRelativePath}'`) } if (entry.isDirectory()) { + assertExternalImportTreeDepth(depth + 1) + admitExternalImportTreeEntry(retentionBudget.tree, childRelativePath, true) entries.push({ relativePath: childRelativePath, kind: 'directory' }) - await visit(childPath) + await visit(childPath, depth + 1) continue } if (!entry.isFile()) { throw new Error(`Unsupported file type in '${childRelativePath}'`) } - const stagedFile = await stageFileEntry(childPath, childRelativePath, { - rootRealPath, - totalBytesBefore: totalBytes - }) - totalBytes += stagedFile.byteLength - entries.push(stagedFile.entry) + admitExternalImportTreeEntry(retentionBudget.tree, childRelativePath, true) + entries.push( + await stageFileEntry(childPath, childRelativePath, { + rootRealPath, + retentionBudget + }) + ) } } - await visit(rootPath) + await visit(rootPath, 0) return entries } async function stageFileEntry( filePath: string, relativePath: string, - options?: { rootRealPath?: string; totalBytesBefore?: number } -): Promise<{ entry: StagedExternalImportEntry; byteLength: number }> { + options: { retentionBudget: RuntimeUploadRetentionBudget; rootRealPath?: string } +): Promise { const statResult = await lstat(filePath) const displayPath = normalizeRelativeUploadPath(relativePath) if (statResult.isSymbolicLink()) { @@ -587,11 +616,7 @@ async function stageFileEntry( if (options?.rootRealPath) { await assertRealPathInsideRoot(options.rootRealPath, filePath, displayPath) } - const initialTotalBytes = - options?.totalBytesBefore === undefined - ? statResult.size - : options.totalBytesBefore + statResult.size - assertRemoteUploadBudget(relativePath, statResult.size, initialTotalBytes) + assertRuntimeUploadFileFits(options.retentionBudget, displayPath, statResult.size) const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) try { const openedStat = await fileHandle.stat() @@ -605,23 +630,17 @@ async function stageFileEntry( ) { throw new Error(`File changed during upload staging: '${displayPath}'`) } - const totalBytes = - options?.totalBytesBefore === undefined - ? openedStat.size - : options.totalBytesBefore + openedStat.size - assertRemoteUploadBudget(relativePath, openedStat.size, totalBytes) - const buffer = await fileHandle.readFile() + assertRuntimeUploadFileFits(options.retentionBudget, displayPath, openedStat.size) + const buffer = await readStableRuntimeUploadFile(fileHandle, openedStat.size, displayPath) const afterReadStat = await fileHandle.stat() if (afterReadStat.size !== openedStat.size) { throw new Error(`File changed during upload staging: '${displayPath}'`) } + retainRuntimeUploadFileBytes(options.retentionBudget, displayPath, buffer.byteLength) return { - entry: { - relativePath: displayPath, - kind: 'file', - contentBase64: buffer.toString('base64') - }, - byteLength: openedStat.size + relativePath: displayPath, + kind: 'file', + contentBase64: buffer.toString('base64') } } finally { await fileHandle.close() @@ -644,19 +663,6 @@ async function assertRealPathInsideRoot( } } -function assertRemoteUploadBudget( - relativePath: string, - fileBytes: number, - totalBytes: number -): void { - if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { - throw new Error(`'${relativePath}' is too large for remote import`) - } - if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) { - throw new Error('Remote import is too large') - } -} - function normalizeRelativeUploadPath(path: string): string { return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') } @@ -666,19 +672,40 @@ function normalizeRelativeUploadPath(path: string): string { * is found anywhere in the subtree. */ async function preScanForSymlinks(dirPath: string): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isSymbolicLink()) { + const budget = createExternalImportTreeBudget() + + async function visit( + currentDirPath: string, + relativeDirPath: string, + depth: number + ): Promise { + assertExternalImportTreeDepth(depth) + const currentStat = await lstat(currentDirPath) + if (currentStat.isSymbolicLink()) { return true } - if (entry.isDirectory()) { - const childPath = join(dirPath, entry.name) - if (await preScanForSymlinks(childPath)) { + if (!currentStat.isDirectory()) { + throw new Error(`Unsupported file type in '${relativeDirPath}'`) + } + + const directory = await opendir(currentDirPath) + for await (const entry of directory) { + const childRelativePath = join(relativeDirPath, entry.name) + admitExternalImportTreeEntry(budget, childRelativePath, false) + if (entry.isSymbolicLink()) { return true } + if (entry.isDirectory()) { + assertExternalImportTreeDepth(depth + 1) + if (await visit(join(currentDirPath, entry.name), childRelativePath, depth + 1)) { + return true + } + } } + return false } - return false + + return visit(dirPath, '', 0) } /** @@ -687,24 +714,47 @@ async function preScanForSymlinks(dirPath: string): Promise { * buffering entire files into memory. */ async function recursiveCopyDir(srcDir: string, destDir: string): Promise { - await mkdir(destDir, { recursive: false }) - const entries = await readdir(srcDir, { withFileTypes: true }) - for (const entry of entries) { - const srcPath = join(srcDir, entry.name) - const dstPath = join(destDir, entry.name) - const statResult = await lstat(srcPath) - if (statResult.isSymbolicLink()) { - throw new Error(`Symlink not allowed in '${entry.name}'`) + const budget = createExternalImportTreeBudget() + + async function copyDirectory( + currentSrcDir: string, + currentDestDir: string, + relativeDirPath: string, + depth: number + ): Promise { + assertExternalImportTreeDepth(depth) + const dirStat = await lstat(currentSrcDir) + if (dirStat.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${relativeDirPath}'`) } - if (statResult.isDirectory()) { - await recursiveCopyDir(srcPath, dstPath) - continue + if (!dirStat.isDirectory()) { + throw new Error(`Unsupported file type in '${relativeDirPath}'`) } - if (!statResult.isFile()) { - throw new Error(`Unsupported file type in '${entry.name}'`) + await mkdir(currentDestDir, { recursive: false }) + + const directory = await opendir(currentSrcDir) + for await (const entry of directory) { + const srcPath = join(currentSrcDir, entry.name) + const dstPath = join(currentDestDir, entry.name) + const childRelativePath = join(relativeDirPath, entry.name) + admitExternalImportTreeEntry(budget, childRelativePath, false) + const statResult = await lstat(srcPath) + if (statResult.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${childRelativePath}'`) + } + if (statResult.isDirectory()) { + assertExternalImportTreeDepth(depth + 1) + await copyDirectory(srcPath, dstPath, childRelativePath, depth + 1) + continue + } + if (!statResult.isFile()) { + throw new Error(`Unsupported file type in '${childRelativePath}'`) + } + await copyLocalFileNoFollow(srcPath, dstPath, statResult) } - await copyLocalFileNoFollow(srcPath, dstPath, statResult) } + + await copyDirectory(srcDir, destDir, '', 0) } async function copyLocalFileNoFollow( diff --git a/src/main/ipc/filesystem-runtime-upload-file-reader.test.ts b/src/main/ipc/filesystem-runtime-upload-file-reader.test.ts new file mode 100644 index 00000000000..d2c3d7f92be --- /dev/null +++ b/src/main/ipc/filesystem-runtime-upload-file-reader.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { readStableRuntimeUploadFile } from './filesystem-runtime-upload-file-reader' + +function createRead(content: Buffer, maxBytesPerRead = content.byteLength) { + return vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min(length, maxBytesPerRead, Math.max(0, content.byteLength - position)) + if (bytesRead > 0) { + content.copy(buffer, offset, position, position + bytesRead) + } + return { buffer, bytesRead } + }) +} + +describe('runtime upload file reader', () => { + it('preserves an exact-size file across partial reads', async () => { + const content = Buffer.from('bounded-content') + const read = createRead(content, 3) + + await expect( + readStableRuntimeUploadFile({ read }, content.byteLength, 'asset.bin') + ).resolves.toEqual(content) + expect(read).toHaveBeenLastCalledWith(expect.any(Buffer), 0, 1, content.byteLength) + }) + + it('rejects a file that becomes shorter without exposing uninitialized bytes', async () => { + const content = Buffer.from('short') + + await expect( + readStableRuntimeUploadFile({ read: createRead(content) }, content.byteLength + 1, '') + ).rejects.toThrow("File changed during upload staging: ''") + }) + + it('uses a one-byte probe and rejects growth beyond the admitted size', async () => { + const content = Buffer.from('growth') + const admittedBytes = content.byteLength - 1 + const read = createRead(content) + + await expect( + readStableRuntimeUploadFile({ read }, admittedBytes, 'growth.bin') + ).rejects.toThrow("File changed during upload staging: 'growth.bin'") + expect(read).toHaveBeenNthCalledWith(1, expect.any(Buffer), 0, admittedBytes, 0) + expect(read).toHaveBeenNthCalledWith(2, expect.any(Buffer), 0, 1, admittedBytes) + }) +}) diff --git a/src/main/ipc/filesystem-runtime-upload-file-reader.ts b/src/main/ipc/filesystem-runtime-upload-file-reader.ts new file mode 100644 index 00000000000..842db049fef --- /dev/null +++ b/src/main/ipc/filesystem-runtime-upload-file-reader.ts @@ -0,0 +1,33 @@ +type RuntimeUploadFileHandle = { + read( + buffer: Buffer, + offset: number, + length: number, + position: number + ): Promise<{ bytesRead: number }> +} + +export async function readStableRuntimeUploadFile( + fileHandle: RuntimeUploadFileHandle, + expectedBytes: number, + displayPath: string +): Promise { + const buffer = Buffer.allocUnsafe(expectedBytes) + let offset = 0 + while (offset < expectedBytes) { + const { bytesRead } = await fileHandle.read(buffer, offset, expectedBytes - offset, offset) + if (bytesRead === 0) { + throw fileChangedError(displayPath) + } + offset += bytesRead + } + const probe = Buffer.allocUnsafe(1) + if ((await fileHandle.read(probe, 0, 1, offset)).bytesRead !== 0) { + throw fileChangedError(displayPath) + } + return buffer +} + +function fileChangedError(displayPath: string): Error { + return new Error(`File changed during upload staging: '${displayPath}'`) +} diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index 065dbd11dad..3a71c83c1ea 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -8,6 +8,7 @@ import { SEARCH_TIMEOUT_MS } from '../../shared/text-search' import { gitSpawn } from '../git/runner' +import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' /** * Fallback text search using git grep. Used when rg is not available. @@ -26,7 +27,7 @@ export function searchWithGitGrep( const gitArgs = buildGitGrepArgs(args.query, args) const matchRegex = buildSubmatchRegex(args.query, args) const acc = createAccumulator() - let stdoutBuffer = '' + const stdoutLines = new SearchSubprocessLineAccumulator() let done = false const child = gitSpawn(gitArgs, { @@ -58,12 +59,11 @@ export function searchWithGitGrep( } } - function handleStdoutData(chunk: string): void { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const l of lines) { - processLine(l) + function handleStdoutData(chunk: Buffer): void { + if (!stdoutLines.push(chunk, processLine)) { + acc.truncated = true + child.kill() + resolveOnce() } } @@ -76,13 +76,13 @@ export function searchWithGitGrep( } function handleClose(): void { - if (stdoutBuffer) { - processLine(stdoutBuffer) + const trailingLine = stdoutLines.finish() + if (trailingLine !== null) { + processLine(trailingLine) } resolveOnce() } - child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) diff --git a/src/main/ipc/filesystem-watcher-admission.test.ts b/src/main/ipc/filesystem-watcher-admission.test.ts new file mode 100644 index 00000000000..0851c0d0886 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-admission.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + FILESYSTEM_WATCHER_MAX_CLAIMS, + FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER, + FILESYSTEM_WATCHER_MAX_PATH_BYTES, + FilesystemWatcherAdmission, + parseFilesystemWatcherIdentity +} from './filesystem-watcher-admission' + +describe('filesystem watcher admission', () => { + it('caps unique claims per sender and recovers after release', () => { + const admission = new FilesystemWatcherAdmission() + const releases = Array.from({ length: FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER }, (_, index) => + admission.claim(1, `local:/repo-${index}`, 1) + ) + + expect(() => admission.claim(1, 'local:/overflow', 1)).toThrow('capacity reached') + expect(admission.evidence().claimCount).toBe(FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER) + + releases[0]?.release() + expect(() => admission.claim(1, 'local:/recovered', 1)).not.toThrow() + }) + + it('caps aggregate claims across senders without double-counting duplicate watches', () => { + const admission = new FilesystemWatcherAdmission() + for (let index = 0; index < FILESYSTEM_WATCHER_MAX_CLAIMS; index += 1) { + admission.claim(index, `local:/repo-${index}`, 1) + } + + expect(admission.claim(0, 'local:/repo-0', 1).added).toBe(false) + expect(() => admission.claim(FILESYSTEM_WATCHER_MAX_CLAIMS, 'local:/overflow', 1)).toThrow( + 'capacity reached' + ) + + admission.releaseSender(0) + expect(() => + admission.claim(FILESYSTEM_WATCHER_MAX_CLAIMS, 'local:/recovered', 1) + ).not.toThrow() + }) + + it('bounds retained path bytes before creating a watcher key', () => { + const exact = 'é'.repeat(FILESYSTEM_WATCHER_MAX_PATH_BYTES / 2) + + expect(parseFilesystemWatcherIdentity({ worktreePath: exact }).retainedBytes).toBe( + FILESYSTEM_WATCHER_MAX_PATH_BYTES + ) + expect(() => parseFilesystemWatcherIdentity({ worktreePath: `${exact}x` })).toThrow( + `exceeds ${FILESYSTEM_WATCHER_MAX_PATH_BYTES} UTF-8 bytes` + ) + }) + + it('does not let a stale release remove a replacement claim', () => { + const admission = new FilesystemWatcherAdmission() + const old = admission.claim(1, 'local:/repo', 1) + admission.clear() + admission.claim(1, 'local:/repo', 1) + + old.release() + + expect(admission.evidence().claimCount).toBe(1) + }) +}) diff --git a/src/main/ipc/filesystem-watcher-admission.ts b/src/main/ipc/filesystem-watcher-admission.ts new file mode 100644 index 00000000000..77acb626853 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-admission.ts @@ -0,0 +1,132 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export const FILESYSTEM_WATCHER_MAX_CLAIMS = 1_024 +export const FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER = 256 +export const FILESYSTEM_WATCHER_MAX_RETAINED_IDENTITY_BYTES = 16 * 1024 * 1024 +export const FILESYSTEM_WATCHER_MAX_PATH_BYTES = 64 * 1024 +export const FILESYSTEM_WATCHER_MAX_CONNECTION_ID_BYTES = 8 * 1024 + +type WatchClaim = { retainedBytes: number } + +export type FilesystemWatcherIdentity = { + worktreePath: string + connectionId?: string + retainedBytes: number +} + +export class FilesystemWatcherAdmission { + private readonly claimsBySender = new Map>() + private claimCount = 0 + private retainedBytes = 0 + + claim( + senderId: number, + key: string, + retainedBytes: number + ): { added: boolean; release: () => void } { + let senderClaims = this.claimsBySender.get(senderId) + const existing = senderClaims?.get(key) + if (existing) { + return { added: false, release: () => undefined } + } + if ( + this.claimCount >= FILESYSTEM_WATCHER_MAX_CLAIMS || + (senderClaims?.size ?? 0) >= FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER || + this.retainedBytes + retainedBytes > FILESYSTEM_WATCHER_MAX_RETAINED_IDENTITY_BYTES + ) { + throw new Error('Filesystem watcher capacity reached; close an existing watch and retry.') + } + senderClaims ??= new Map() + this.claimsBySender.set(senderId, senderClaims) + const claim = { retainedBytes } + senderClaims.set(key, claim) + this.claimCount += 1 + this.retainedBytes += retainedBytes + return { + added: true, + release: () => this.releaseClaim(senderId, key, claim) + } + } + + release(senderId: number, key: string): void { + const claim = this.claimsBySender.get(senderId)?.get(key) + if (claim) { + this.releaseClaim(senderId, key, claim) + } + } + + releaseSender(senderId: number): void { + const senderClaims = this.claimsBySender.get(senderId) + if (!senderClaims) { + return + } + this.claimsBySender.delete(senderId) + for (const claim of senderClaims.values()) { + this.claimCount -= 1 + this.retainedBytes -= claim.retainedBytes + } + } + + clear(): void { + this.claimsBySender.clear() + this.claimCount = 0 + this.retainedBytes = 0 + } + + evidence(): { claimCount: number; retainedBytes: number; senderCount: number } { + return { + claimCount: this.claimCount, + retainedBytes: this.retainedBytes, + senderCount: this.claimsBySender.size + } + } + + private releaseClaim(senderId: number, key: string, expected: WatchClaim): void { + const senderClaims = this.claimsBySender.get(senderId) + if (senderClaims?.get(key) !== expected) { + return + } + senderClaims.delete(key) + this.claimCount -= 1 + this.retainedBytes -= expected.retainedBytes + if (senderClaims.size === 0) { + this.claimsBySender.delete(senderId) + } + } +} + +export function parseFilesystemWatcherIdentity(value: unknown): FilesystemWatcherIdentity { + if (!value || typeof value !== 'object') { + throw new TypeError('Filesystem watcher arguments are required.') + } + const record = value as Record + const worktreePath = record.worktreePath + const connectionId = record.connectionId + if (typeof worktreePath !== 'string' || worktreePath.length === 0) { + throw new TypeError('Filesystem watcher worktreePath must be a non-empty string.') + } + const pathBytes = boundedFieldBytes( + worktreePath, + FILESYSTEM_WATCHER_MAX_PATH_BYTES, + 'worktreePath' + ) + if (connectionId !== undefined && typeof connectionId !== 'string') { + throw new TypeError('Filesystem watcher connectionId must be a string.') + } + const connectionBytes = connectionId + ? boundedFieldBytes(connectionId, FILESYSTEM_WATCHER_MAX_CONNECTION_ID_BYTES, 'connectionId') + : 0 + return { + worktreePath, + ...(connectionId ? { connectionId } : {}), + retainedBytes: pathBytes + connectionBytes + } +} + +function boundedFieldBytes(value: string, limit: number, field: string): number { + const measured = measureUtf8ByteLength(value, { stopAfterBytes: limit }) + if (measured.exceededLimit) { + throw new TypeError(`Filesystem watcher ${field} exceeds ${limit} UTF-8 bytes.`) + } + return measured.byteLength +} diff --git a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts deleted file mode 100644 index f557ebca379..00000000000 --- a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn(), - providerRegistrationListeners: new Set<(connectionId: string) => void>() - }) -) - -vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) -vi.mock('fs/promises', () => ({ stat: vi.fn() })) -vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) -vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock, - onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { - providerRegistrationListeners.add(listener) - return () => providerRegistrationListeners.delete(listener) - } -})) - -import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' - -type HandlerMap = Record Promise | unknown> - -const WORKTREE_PATH = '/home/me/repo' -const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' } -const OVERFLOW_PAYLOAD = { - worktreePath: WORKTREE_PATH, - events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }] -} -const RETRY_GIVE_UP_MS = 61_000 -const DORMANT_FIRST_MS = 60_000 - -function createSender(id: number): { - isDestroyed: () => boolean - send: ReturnType - once: ReturnType - id: number -} { - return { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id } -} - -describe('remote filesystem watcher dormant re-arm', () => { - const handlers: HandlerMap = {} - let warnSpy: ReturnType - - afterEach(() => { - warnSpy.mockRestore() - }) - - beforeEach(async () => { - vi.useRealTimers() - handleMock.mockReset() - getSshFilesystemProviderMock.mockReset() - for (const key of Object.keys(handlers)) { - delete handlers[key] - } - handleMock.mockImplementation((channel, handler) => { - handlers[channel] = handler - }) - registerFilesystemWatcherHandlers() - await closeAllWatchers() - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - /** Install a live watch, kill it terminally, then burn the 1s/60s fast retry window out. */ - async function installThenGiveUp( - sender: ReturnType, - watchAfterDeath: ReturnType - ): Promise { - let onTerminalError: (error: Error) => void = () => {} - getSshFilesystemProviderMock.mockReturnValue({ - watch: vi.fn().mockImplementation((_path, _callback, options) => { - onTerminalError = options.onTerminalError - return Promise.resolve(vi.fn()) - }) - }) - await handlers['fs:watchWorktree']({ sender }, ARGS) - - // The SSH link stays up: only the remote watcher dies (inotify exhaustion, relay watcher killed). - getSshFilesystemProviderMock.mockReturnValue({ watch: watchAfterDeath }) - onTerminalError(new Error('remote watcher died')) - await vi.advanceTimersByTimeAsync(RETRY_GIVE_UP_MS) - expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) - sender.send.mockClear() - } - - it('re-arms after the fast retry window gives up, with no reconnect to trigger it', async () => { - vi.useFakeTimers() - const sender = createSender(1) - const failingWatch = vi.fn().mockRejectedValue(new Error('inotify limit reached')) - await installThenGiveUp(sender, failingWatch) - - const recoveredWatch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch: recoveredWatch }) - await vi.advanceTimersByTimeAsync(DORMANT_FIRST_MS) - - expect(recoveredWatch).toHaveBeenCalledTimes(1) - expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) - - await closeAllWatchers() - vi.useRealTimers() - }) - - it('backs the re-arm off instead of hammering a host that keeps refusing', async () => { - vi.useFakeTimers() - const sender = createSender(1) - const failingWatch = vi.fn().mockRejectedValue(new Error('inotify limit reached')) - await installThenGiveUp(sender, failingWatch) - failingWatch.mockClear() - - await vi.advanceTimersByTimeAsync(DORMANT_FIRST_MS) - expect(failingWatch).toHaveBeenCalledTimes(1) - - // Half an hour of a permanently broken remote watcher must stay in single digits, not 1s retries. - await vi.advanceTimersByTimeAsync(30 * 60_000) - expect(failingWatch.mock.calls.length).toBeLessThanOrEqual(6) - expect(failingWatch.mock.calls.length).toBeGreaterThan(1) - - await closeAllWatchers() - vi.useRealTimers() - }) - - it('stops re-arming once the renderer unwatches', async () => { - vi.useFakeTimers() - const sender = createSender(1) - const failingWatch = vi.fn().mockRejectedValue(new Error('inotify limit reached')) - await installThenGiveUp(sender, failingWatch) - failingWatch.mockClear() - - handlers['fs:unwatchWorktree']({ sender }, ARGS) - await vi.advanceTimersByTimeAsync(60 * 60_000) - - expect(failingWatch).not.toHaveBeenCalled() - - await closeAllWatchers() - vi.useRealTimers() - }) - - it('leaves a dropped connection to the provider-registration re-arm', async () => { - vi.useFakeTimers() - const sender = createSender(1) - const failingWatch = vi.fn().mockRejectedValue(new Error('relay gone')) - await installThenGiveUp(sender, failingWatch) - failingWatch.mockClear() - - // Provider gone == connection down; its registration is the cheaper trigger, so don't poll. - getSshFilesystemProviderMock.mockReturnValue(undefined) - await vi.advanceTimersByTimeAsync(60 * 60_000) - - const recoveredWatch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch: recoveredWatch }) - await vi.advanceTimersByTimeAsync(60 * 60_000) - expect(recoveredWatch).not.toHaveBeenCalled() - - for (const listener of providerRegistrationListeners) { - listener('conn-1') - } - await vi.advanceTimersByTimeAsync(0) - - expect(recoveredWatch).toHaveBeenCalledTimes(1) - expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) - - await closeAllWatchers() - vi.useRealTimers() - }) -}) diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts index 01f7ccc90cc..2783ca95f0c 100644 --- a/src/main/ipc/filesystem-watcher-large-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -24,8 +24,7 @@ vi.mock('./filesystem-watcher-wsl', () => ({ })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn(), - onSshFilesystemProviderRegistered: () => () => {} + getSshFilesystemProvider: vi.fn() })) import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index 0e15ad43fc7..54f712c18ad 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -32,8 +32,7 @@ vi.mock('./parcel-watcher-process', async (importOriginal) => { }) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn(), - onSshFilesystemProviderRegistered: () => () => {} + getSshFilesystemProvider: vi.fn() })) import { @@ -143,6 +142,26 @@ describe('local filesystem watcher unsubscribe cleanup', () => { expect(shutdownResolved).toBe(true) }) + it('releases watcher admission after repeated subscription errors', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let watcherCallback: (err: Error | null, events: []) => void = () => {} + vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => { + watcherCallback = callback as typeof watcherCallback + return { unsubscribe: vi.fn() } as never + }) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + for (let index = 0; index < 257; index += 1) { + await expect( + handlers['fs:watchWorktree']({ sender }, { worktreePath: `/tmp/erroring-repo-${index}` }) + ).resolves.toBeUndefined() + watcherCallback(new Error('root disappeared'), []) + } + + errorSpy.mockRestore() + }) + it('unsubscribes if the sender is destroyed while the local watcher is opening', async () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) const destroyedCallbacks: (() => void)[] = [] diff --git a/src/main/ipc/filesystem-watcher-native-capacity.test.ts b/src/main/ipc/filesystem-watcher-native-capacity.test.ts index 41db2d35e52..658793e2f5e 100644 --- a/src/main/ipc/filesystem-watcher-native-capacity.test.ts +++ b/src/main/ipc/filesystem-watcher-native-capacity.test.ts @@ -16,8 +16,7 @@ vi.mock('./parcel-watcher-process', () => ({ })) vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn(), - onSshFilesystemProviderRegistered: () => () => {} + getSshFilesystemProvider: vi.fn() })) import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts index f4af15588be..3e22cb6b835 100644 --- a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -13,8 +13,7 @@ vi.mock('fs/promises', () => ({ stat: vi.fn() })) vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock, - onSshFilesystemProviderRegistered: () => () => {} + getSshFilesystemProvider: getSshFilesystemProviderMock })) import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' diff --git a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts deleted file mode 100644 index 869c8bebf45..00000000000 --- a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn(), - providerRegistrationListeners: new Set<(connectionId: string) => void>() - }) -) - -/** Drive the provider-registration hook the way a relay establish/reconnect would. */ -function emitProviderRegistered(connectionId: string): void { - for (const listener of providerRegistrationListeners) { - listener(connectionId) - } -} - -vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) -vi.mock('fs/promises', () => ({ stat: vi.fn() })) -vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) -vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock, - onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { - providerRegistrationListeners.add(listener) - return () => providerRegistrationListeners.delete(listener) - } -})) - -import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' - -type HandlerMap = Record Promise | unknown> - -describe('remote filesystem watcher re-arm', () => { - const handlers: HandlerMap = {} - - beforeEach(async () => { - vi.useRealTimers() - handleMock.mockReset() - getSshFilesystemProviderMock.mockReset() - for (const key of Object.keys(handlers)) { - delete handlers[key] - } - handleMock.mockImplementation((channel, handler) => { - handlers[channel] = handler - }) - registerFilesystemWatcherHandlers() - await closeAllWatchers() - }) - - it('still resyncs when a fresh watch beat the failed reinstall to the retry slot', async () => { - vi.useFakeTimers() - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } - const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockResolvedValue(vi.fn()) }) - - await handlers['fs:watchWorktree']({ sender: senderOne }, args) - - // Hold the reinstall's fs.watch open so a second renderer joins it and claims the retry slot first. - let failReinstall: (error: Error) => void = () => {} - const heldWatch = new Promise((_resolve, reject) => { - failReinstall = reject - }) - getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockReturnValue(heldWatch) }) - senderOne.send.mockClear() - emitProviderRegistered('conn-1') - - const joinedWatch = handlers['fs:watchWorktree']({ sender: senderTwo }, args) - const retryWatchMock = vi.fn().mockResolvedValue(vi.fn()) - failReinstall(new Error('relay not ready')) - getSshFilesystemProviderMock.mockReturnValue({ watch: retryWatchMock }) - await joinedWatch - await vi.advanceTimersByTimeAsync(1_000) - - // senderOne's watch really died with the old transport, so its resync must survive the merge. - expect(retryWatchMock).toHaveBeenCalledTimes(1) - expect(senderOne.send).toHaveBeenCalledWith('fs:changed', { - worktreePath: '/home/me/repo', - events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] - }) - - warnSpy.mockRestore() - await closeAllWatchers() - vi.useRealTimers() - }) -}) diff --git a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts index d629d74dc02..8ad05fc4ab1 100644 --- a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts +++ b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts @@ -23,8 +23,7 @@ vi.mock('./filesystem-watcher-wsl', () => ({ })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn(), - onSshFilesystemProviderRegistered: () => () => {} + getSshFilesystemProvider: vi.fn() })) import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 4c8333d508e..955013a86b8 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -100,6 +100,17 @@ describe('createWslWatcher', () => { ) }) + it('parses a snapshot delivered as 100,000 one-byte fragments', async () => { + const { child, promise } = startWatcher() + const frame = Buffer.from(snapshotFrame([['f', '1.0', `/home/me/repo/${'x'.repeat(99_950)}`]])) + + for (let index = 0; index < frame.byteLength; index += 1) { + child.stdout.write(frame.subarray(index, index + 1)) + } + + await expect(promise).resolves.toBeDefined() + }) + it('counts WSL watcher processes against the global physical child cap', async () => { const releases = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => reserveWatcherChild() diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index b73f58d5122..366f3068405 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -6,13 +6,14 @@ * inside the distro so shutdown kills it instead of Orca restarting WSL. */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { StringDecoder } from 'node:string_decoder' import type { WebContents } from 'electron' import type { Event as WatcherEvent } from '@parcel/watcher' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import { queueWatcherEvents } from './filesystem-watcher-event-batch' import { parseWslUncPath } from '../../shared/wsl-paths' import { createWslWatcherProcessExit, createWslWatcherStartup } from './wsl-watcher-process-exit' import { reserveWatcherChild, WatcherChildCapacityError } from './parcel-watcher-child-registry' +import { diffWslSnapshots, parseWslSnapshotFrame, type WslSnapshot } from './wsl-watcher-snapshot' export type WatcherSubscription = { unsubscribe(): Promise @@ -41,19 +42,7 @@ export type WslWatcherDeps = { const POLL_INTERVAL_SECONDS = 2 const STARTUP_TIMEOUT_MS = 10_000 const [SNAPSHOT_START, SNAPSHOT_END] = ['\x1e', '\x1f'] -const MAX_STREAM_BUFFER_CHARS = 10 * 1024 * 1024 - -type WslSnapshotEntry = { - path: string - type: string - mtime: string -} - -type WslSnapshot = Map - -function toWslUncPath(linuxPath: string, distro: string): string { - return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` -} +export const MAX_WSL_SNAPSHOT_FRAME_BYTES = 10 * 1024 * 1024 function quoteSafeFindName(name: string): string { if (!/^[A-Za-z0-9_.-]+$/.test(name)) { @@ -86,59 +75,6 @@ function buildSnapshotScript(ignoreDirs: readonly string[]): string { ].join('\n') } -function parseSnapshotFrame(frame: string, distro: string): WslSnapshot { - const snapshot: WslSnapshot = new Map() - for (const rawEntry of frame.split('\0')) { - if (!rawEntry) { - continue - } - const firstTab = rawEntry.indexOf('\t') - const secondTab = firstTab === -1 ? -1 : rawEntry.indexOf('\t', firstTab + 1) - if (firstTab <= 0 || secondTab <= firstTab + 1) { - continue - } - const linuxPath = rawEntry.slice(secondTab + 1) - if (!linuxPath.startsWith('/')) { - continue - } - const entry: WslSnapshotEntry = { - type: rawEntry.slice(0, firstTab), - mtime: rawEntry.slice(firstTab + 1, secondTab), - path: toWslUncPath(linuxPath, distro) - } - snapshot.set(entry.path, entry) - } - return snapshot -} - -function diffSnapshots(prev: WslSnapshot, next: WslSnapshot): WatcherEvent[] { - const events: WatcherEvent[] = [] - - for (const [entryPath, nextEntry] of next) { - const prevEntry = prev.get(entryPath) - if (!prevEntry) { - events.push({ type: 'create', path: entryPath } as WatcherEvent) - continue - } - if (prevEntry.type !== nextEntry.type) { - events.push({ type: 'delete', path: entryPath } as WatcherEvent) - events.push({ type: 'create', path: entryPath } as WatcherEvent) - continue - } - if (prevEntry.mtime !== nextEntry.mtime) { - events.push({ type: 'update', path: entryPath } as WatcherEvent) - } - } - - for (const entryPath of prev.keys()) { - if (!next.has(entryPath)) { - events.push({ type: 'delete', path: entryPath } as WatcherEvent) - } - } - - return events -} - function markOverflowWithoutUncStat(root: WatchedRoot): void { if (root.batch.timer) { clearTimeout(root.batch.timer) @@ -176,13 +112,21 @@ export async function createWslWatcher( let disposed = false let prevSnapshot: WslSnapshot | null = null let stopped = false - let streamBuffer = '' - const stdoutDecoder = new StringDecoder('utf8') - const stderrDecoder = new StringDecoder('utf8') - let stderrTail = '' + const streamBuffer = new GrowingByteBuffer() + const stderrTail = new GrowingByteBuffer() const startup = createWslWatcherStartup() + function reportSnapshotOverflow(message: string): void { + if (!startup.settled) { + startup.settle(new Error(message)) + return + } + prevSnapshot = new Map() + markOverflowWithoutUncStat(root) + deps.scheduleBatchFlush(rootKey, root) + } + function signalWatcherStopped(): void { if (stopped) { return @@ -197,13 +141,17 @@ export async function createWslWatcher( } function ingestFrame(frame: string): void { - const nextSnapshot = parseSnapshotFrame(frame, distro) + const nextSnapshot = parseWslSnapshotFrame(frame, distro) + if (!nextSnapshot) { + reportSnapshotOverflow('WSL watcher snapshot exceeded its retained entry limit') + return + } if (!prevSnapshot) { prevSnapshot = nextSnapshot startup.settle() return } - const events = diffSnapshots(prevSnapshot, nextSnapshot) + const events = diffWslSnapshots(prevSnapshot, nextSnapshot) prevSnapshot = nextSnapshot if (events.length > 0) { @@ -214,25 +162,30 @@ export async function createWslWatcher( function drainFrames(): void { while (true) { - const start = streamBuffer.indexOf(SNAPSHOT_START) + const start = streamBuffer.indexOfByte(SNAPSHOT_START.charCodeAt(0)) if (start === -1) { - streamBuffer = streamBuffer.slice(-1) + streamBuffer.retainSuffix(1) return } if (start > 0) { - streamBuffer = streamBuffer.slice(start) + streamBuffer.discardPrefix(start) } - const end = streamBuffer.indexOf(SNAPSHOT_END, 1) + const end = streamBuffer.indexOfByte(SNAPSHOT_END.charCodeAt(0), 1) if (end === -1) { - if (streamBuffer.length > MAX_STREAM_BUFFER_CHARS) { - streamBuffer = '' - markOverflowWithoutUncStat(root) - deps.scheduleBatchFlush(rootKey, root) + if (streamBuffer.byteLength > MAX_WSL_SNAPSHOT_FRAME_BYTES) { + streamBuffer.clear() + reportSnapshotOverflow('WSL watcher snapshot exceeded its frame byte limit') } return } - const frame = streamBuffer.slice(1, end) - streamBuffer = streamBuffer.slice(end + 1) + if (end - 1 > MAX_WSL_SNAPSHOT_FRAME_BYTES) { + streamBuffer.discardPrefix(end + 1) + reportSnapshotOverflow('WSL watcher snapshot exceeded its frame byte limit') + continue + } + const frameWithStart = streamBuffer.takePrefixString(end) + streamBuffer.discardPrefix(1) + const frame = frameWithStart.slice(1) ingestFrame(frame) } } @@ -279,12 +232,18 @@ export async function createWslWatcher( if (disposed) { return } - streamBuffer += stdoutDecoder.write(chunk) + streamBuffer.append(chunk) drainFrames() }) child.stderr.on('data', (chunk: Buffer) => { - stderrTail = (stderrTail + stderrDecoder.write(chunk)).slice(-4096) + if (chunk.byteLength >= 4096) { + stderrTail.clear() + stderrTail.append(chunk.subarray(chunk.byteLength - 4096)) + return + } + stderrTail.append(chunk) + stderrTail.retainSuffix(4096) }) child.stdout.on('error', (error) => { @@ -319,7 +278,8 @@ export async function createWslWatcher( child.once('close', (code, signal) => { processExit.markPhysicalExit() if (!startup.settled) { - const suffix = stderrTail.trim() ? `: ${stderrTail.trim()}` : '' + const stderr = stderrTail.toString() + const suffix = stderr.trim() ? `: ${stderr.trim()}` : '' startup.settle( new Error(`WSL watcher exited before first snapshot (${code ?? signal})${suffix}`) ) diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 4687a6b71e1..d13221acf41 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -1,19 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn(), - providerRegistrationListeners: new Set<(connectionId: string) => void>() - }) -) - -/** Drive the provider-registration hook the way a relay establish/reconnect would. */ -function emitProviderRegistered(connectionId: string): void { - for (const listener of providerRegistrationListeners) { - listener(connectionId) - } -} +const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() +})) vi.mock('electron', () => ({ ipcMain: { @@ -34,17 +24,13 @@ vi.mock('./filesystem-watcher-wsl', () => ({ })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock, - onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { - providerRegistrationListeners.add(listener) - return () => providerRegistrationListeners.delete(listener) - } + getSshFilesystemProvider: getSshFilesystemProviderMock })) import { closeAllWatchers, closeRemoteWatcherForWorktreePath, - forgetRemoteWatcherRemovalSnapshot, + LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY, registerFilesystemWatcherHandlers, restoreRemoteWatcherAfterFailedRemoval } from './filesystem-watcher' @@ -58,6 +44,7 @@ import { WatcherChildCapacityError } from './parcel-watcher-child-registry' import { acquireWatcherRemovalGate } from './watcher-removal-gate' +import { FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER } from './filesystem-watcher-admission' type HandlerMap = Record Promise | unknown> @@ -109,6 +96,99 @@ describe('registerFilesystemWatcherHandlers', () => { await closeAllWatchers() }) + it('rejects pending watcher churn at the sender cap and recovers after unwatch', async () => { + vi.useFakeTimers() + getSshFilesystemProviderMock.mockReturnValue(undefined) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + for (let index = 0; index < FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER; index += 1) { + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: `/missing/repo-${index}`, connectionId: 'conn-1' } + ) + } + await expect( + handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/missing/overflow', connectionId: 'conn-1' } + ) + ).rejects.toThrow('Filesystem watcher capacity reached') + + handlers['fs:unwatchWorktree']( + { sender }, + { worktreePath: '/missing/repo-0', connectionId: 'conn-1' } + ) + await expect( + handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/missing/recovered', connectionId: 'conn-1' } + ) + ).resolves.toBeUndefined() + await closeAllWatchers() + warn.mockRestore() + vi.useRealTimers() + }) + + it.each([ + ['at the limit', LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY], + ['above the limit', LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY + 1] + ])('bounds event directory stats %s', async (_, count) => { + let active = 0 + let peak = 0 + let started = 0 + const releases: (() => void)[] = [] + vi.mocked(stat).mockImplementation((filePath) => { + if (filePath === '/repo') { + return Promise.resolve({ isDirectory: () => true } as never) + } + started++ + active++ + peak = Math.max(peak, active) + return new Promise((resolve) => { + releases.push(() => { + active-- + resolve({ isDirectory: () => false } as never) + }) + }) + }) + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/repo' }) + const onEvents = vi.mocked(subscribeParcelWatcher).mock.calls[0][1] as ( + error: Error | null, + events: { type: 'create'; path: string }[] + ) => void + + vi.useFakeTimers() + onEvents( + null, + Array.from({ length: count }, (_, index) => ({ + type: 'create', + path: `/repo/file-${index}` + })) + ) + await vi.advanceTimersByTimeAsync(150) + expect(started).toBe(Math.min(count, LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY)) + if (count > LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY) { + releases.shift()?.() + for (let turn = 0; turn < 5 && started < count; turn++) { + await Promise.resolve() + } + expect(started).toBe(count) + } + releases.splice(0).forEach((release) => release()) + + expect(peak).toBe(Math.min(count, LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY)) + await vi.waitFor(() => + expect(sender.send).toHaveBeenCalledWith( + 'fs:changed', + expect.objectContaining({ events: expect.arrayContaining([expect.any(Object)]) }) + ) + ) + vi.useRealTimers() + }) + it('automatically retries a WSL watcher when child capacity becomes available', async () => { Object.defineProperty(process, 'platform', { configurable: true, @@ -342,195 +422,6 @@ describe('registerFilesystemWatcherHandlers', () => { vi.useRealTimers() }) - it('reinstalls an SSH worktree watch when the provider is re-registered after a reconnect', async () => { - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const staleUnwatch = vi.fn() - const watchMock = vi.fn().mockResolvedValue(staleUnwatch) - getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) - - await handlers['fs:watchWorktree']( - { sender }, - { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - ) - expect(watchMock).toHaveBeenCalledTimes(1) - - // The reconnect replaces the provider; the watch made on the dead transport can never fire again. - emitProviderRegistered('conn-1') - await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)) - expect(staleUnwatch).toHaveBeenCalledTimes(1) - - // Events missed while the watch was down are unrecoverable, so consumers are told to resync. - await vi.waitFor(() => - expect(sender.send).toHaveBeenCalledWith('fs:changed', { - worktreePath: '/home/me/repo', - events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] - }) - ) - - const reinstalledEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void - reinstalledEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) - expect(sender.send).toHaveBeenCalledWith('fs:changed', { - worktreePath: '/home/me/repo', - events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] - }) - - await closeAllWatchers() - }) - - it('re-arms an SSH watch whose first install found no provider yet', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - // A connect slower than the retry window leaves the renderer subscribed with nothing installed. - getSshFilesystemProviderMock.mockReturnValue(undefined) - - await handlers['fs:watchWorktree']( - { sender }, - { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - ) - - const watchMock = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) - emitProviderRegistered('conn-1') - - await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(1)) - warnSpy.mockRestore() - await closeAllWatchers() - }) - - it('resyncs after a reconnect whose reinstall only succeeded on a retry', async () => { - vi.useFakeTimers() - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockResolvedValue(vi.fn()) }) - - await handlers['fs:watchWorktree']( - { sender }, - { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - ) - - // The relay is back, but its first fs.watch on the fresh transport still fails. - const retryWatchMock = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock - .mockReturnValueOnce({ watch: vi.fn().mockRejectedValue(new Error('relay not ready')) }) - .mockReturnValue({ watch: retryWatchMock }) - sender.send.mockClear() - emitProviderRegistered('conn-1') - await vi.advanceTimersByTimeAsync(1_000) - - expect(retryWatchMock).toHaveBeenCalledTimes(1) - expect(sender.send).toHaveBeenCalledWith('fs:changed', { - worktreePath: '/home/me/repo', - events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] - }) - - warnSpy.mockRestore() - await closeAllWatchers() - vi.useRealTimers() - }) - - it('does not resurrect an SSH watch the renderer already unwatched', async () => { - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const watchMock = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) - - await handlers['fs:watchWorktree']( - { sender }, - { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - ) - handlers['fs:unwatchWorktree']( - { sender }, - { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - ) - - emitProviderRegistered('conn-1') - await Promise.resolve() - await Promise.resolve() - - expect(watchMock).toHaveBeenCalledTimes(1) - await closeAllWatchers() - }) - - it('leaves watches on other connections untouched when one provider re-registers', async () => { - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const watchMock = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) - - await handlers['fs:watchWorktree']( - { sender }, - { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - ) - - emitProviderRegistered('conn-2') - await Promise.resolve() - await Promise.resolve() - - expect(watchMock).toHaveBeenCalledTimes(1) - await closeAllWatchers() - }) - - it('reinstalls one shared watch when several senders share a re-registered connection', async () => { - const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } - const watchMock = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) - - await handlers['fs:watchWorktree']( - { sender: senderOne }, - { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - ) - await handlers['fs:watchWorktree']( - { sender: senderTwo }, - { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - ) - expect(watchMock).toHaveBeenCalledTimes(1) - - // Per-listener reinstall must still collapse onto one relay watch, and every listener resyncs. - emitProviderRegistered('conn-1') - await vi.waitFor(() => expect(senderTwo.send).toHaveBeenCalled()) - expect(watchMock).toHaveBeenCalledTimes(2) - for (const sender of [senderOne, senderTwo]) { - expect(sender.send).toHaveBeenCalledWith('fs:changed', { - worktreePath: '/home/me/repo', - events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] - }) - } - - await closeAllWatchers() - }) - - it('does not reinstall an SSH watch for a renderer that was destroyed', async () => { - let destroyed = false - const destroyHandlers: (() => void)[] = [] - const sender = { - isDestroyed: () => destroyed, - send: vi.fn(), - once: vi.fn((_event: string, handler: () => void) => { - destroyHandlers.push(handler) - }), - id: 1 - } - const watchMock = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) - - await handlers['fs:watchWorktree']( - { sender }, - { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - ) - expect(destroyHandlers).toHaveLength(1) - - destroyed = true - for (const handler of destroyHandlers) { - handler() - } - - emitProviderRegistered('conn-1') - await Promise.resolve() - await Promise.resolve() - - expect(watchMock).toHaveBeenCalledTimes(1) - await closeAllWatchers() - }) - it('shares SSH worktree watchers across renderer senders until the last unwatch', async () => { const sendOne = vi.fn() const sendTwo = vi.fn() @@ -627,29 +518,6 @@ describe('registerFilesystemWatcherHandlers', () => { }) }) - it('does not re-arm an SSH watch for a worktree that was successfully deleted', async () => { - const watchMock = vi.fn().mockResolvedValue(vi.fn()) - const closeWatch = vi.fn().mockResolvedValue(undefined) - getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - - await handlers['fs:watchWorktree']( - { sender }, - { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - ) - await closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo') - forgetRemoteWatcherRemovalSnapshot('conn-1', '/home/me/repo') - - // A reconnect can land before the renderer's unwatch; the path no longer exists on the host. - emitProviderRegistered('conn-1') - await Promise.resolve() - await Promise.resolve() - - expect(watchMock).toHaveBeenCalledTimes(1) - expect(sender.send).not.toHaveBeenCalled() - await closeAllWatchers() - }) - it('does not restore an SSH listener stopped while deletion is pending', async () => { const firstUnwatch = vi.fn() const watchMock = vi.fn().mockResolvedValue(firstUnwatch) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index b217f51e5b4..6e4d0d5e783 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -8,13 +8,11 @@ import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' import { isWslPath } from '../wsl' import { createWslWatcher } from './filesystem-watcher-wsl' import type { WatchedRoot } from './filesystem-watcher-wsl' -import { - getSshFilesystemProvider, - onSshFilesystemProviderRegistered -} from '../providers/ssh-filesystem-dispatch' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process' import { isWatcherProcessFailure } from './parcel-watcher-process-failure' @@ -23,6 +21,11 @@ import { WatcherChildCapacityError } from './parcel-watcher-child-registry' import { beginWatcherInstall, isWatcherRemovalInProgressError } from './watcher-removal-gate' +import { + FilesystemWatcherAdmission, + parseFilesystemWatcherIdentity, + type FilesystemWatcherIdentity +} from './filesystem-watcher-admission' // Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows). import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' @@ -30,6 +33,8 @@ import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesyst const DEBOUNCE_TRAILING_MS = 150 const DEBOUNCE_MAX_WAIT_MS = 500 +export const LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY = 8 +const WATCHER_LISTENER_INSTALL_CONCURRENCY = 8 // ── Per-root watcher state ─────────────────────────────────────────── // WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape. @@ -37,6 +42,7 @@ const DEBOUNCE_MAX_WAIT_MS = 500 // ── Module state ───────────────────────────────────────────────────── const watchedRoots = new Map() +const watcherAdmission = new FilesystemWatcherAdmission() // Why: cache roots that failed watcher creation (e.g. WSL UNC paths) so we don't retry every worktree switch and spam the console with errors. const UNWATCHABLE_ROOT_CACHE_MAX = 256 @@ -154,8 +160,10 @@ function scheduleLocalCapacityRetry( return } pendingLocalCapacityRetries.delete(rootKey) - await Promise.all( - [...retry.listeners.values()].map(async (listener) => { + await mapWithConcurrency( + [...retry.listeners.values()], + WATCHER_LISTENER_INSTALL_CONCURRENCY, + async (listener) => { if (listener.isDestroyed()) { return } @@ -164,7 +172,7 @@ function scheduleLocalCapacityRetry( console.error(`[filesystem-watcher] capacity retry failed for ${rootKey}:`, error) } }) - }) + } ) }) retry = { listeners: new Map(), cancelWait } @@ -202,6 +210,26 @@ function localWatcherRoot(rootPath: string): { key: string; path: string } { } } +function watcherAdmissionKey(identity: FilesystemWatcherIdentity): string { + return identity.connectionId + ? `remote:${remoteWatcherKey(identity.connectionId, identity.worktreePath)}` + : `local:${localWatcherRoot(identity.worktreePath).key}` +} + +function hasRetainedLocalWatcherListener(rootKey: string, senderId: number): boolean { + return ( + watchedRoots.get(rootKey)?.listeners.has(senderId) === true || + inFlightLocalInstalls.get(rootKey)?.listeners.has(senderId) === true || + pendingLocalCapacityRetries.get(rootKey)?.listeners.has(senderId) === true + ) +} + +function releaseLocalWatcherAdmissions(rootKey: string, listeners: Map): void { + for (const senderId of listeners.keys()) { + watcherAdmission.release(senderId, `local:${rootKey}`) + } +} + function normalizeEventPath(eventPath: string): string { let resolved = path.resolve(eventPath) if (/^[a-zA-Z]:/.test(resolved)) { @@ -306,8 +334,10 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { const coalesced = coalesceEvents(rawEvents) - const events: FsChangeEvent[] = await Promise.all( - coalesced.map(async (evt) => { + const events = await mapWithConcurrency( + coalesced, + LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY, + async (evt): Promise => { // Why: a deleted path can't be stat'd; leave isDirectory undefined and let the renderer infer from dirCache. const isDirectory = evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path) @@ -316,7 +346,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { absolutePath: evt.path, isDirectory } - }) + } ) const payload: FsChangedPayload = { @@ -400,6 +430,7 @@ async function createWatcher( retainLocalWatcherPhysicalFailure(rootKey, err) void trackLocalUnsubscribe(rootKey, root) } + releaseLocalWatcherAdmissions(rootKey, root.listeners) errorCleanedUp = true watchedRoots.delete(rootKey) return @@ -435,6 +466,7 @@ async function createWatcher( // ── Subscribe / Unsubscribe ────────────────────────────────────────── function cleanupLocalWatchersForSender(senderId: number): void { + watcherAdmission.releaseSender(senderId) for (const [rootKey, suspended] of suspendedLocalWatcherListeners) { suspended.listeners.delete(senderId) if (suspended.listeners.size === 0) { @@ -660,11 +692,13 @@ async function doInstallLocalWatcher( if (!s.isDirectory()) { console.warn(`[filesystem-watcher] not a directory: ${rootKey}`) rememberUnwatchableRoot(rootKey) + finishInFlightLocalInstall(rootKey, cancelToken) return 'unavailable' } } catch { console.warn(`[filesystem-watcher] cannot stat root: ${rootKey}`) rememberUnwatchableRoot(rootKey) + finishInFlightLocalInstall(rootKey, cancelToken) return 'unavailable' } @@ -699,9 +733,7 @@ async function doInstallLocalWatcher( rememberUnwatchableRoot(rootKey) return 'unavailable' } finally { - if (inFlightLocalInstalls.get(rootKey) === cancelToken) { - inFlightLocalInstalls.delete(rootKey) - } + finishInFlightLocalInstall(rootKey, cancelToken) } const liveListeners = new Map( @@ -723,6 +755,12 @@ async function doInstallLocalWatcher( return 'installed' } +function finishInFlightLocalInstall(rootKey: string, cancelToken: LocalWatcherInstallToken): void { + if (inFlightLocalInstalls.get(rootKey) === cancelToken) { + inFlightLocalInstalls.delete(rootKey) + } +} + function unsubscribe(worktreePath: string, senderId: number): void { const { key: rootKey } = localWatcherRoot(worktreePath) const suspended = suspendedLocalWatcherListeners.get(rootKey) @@ -868,7 +906,12 @@ export async function restoreLocalWatcherAfterFailedRemoval(worktreePath: string } export function forgetLocalWatcherRemovalSnapshot(worktreePath: string): void { - suspendedLocalWatcherListeners.delete(localWatcherRoot(worktreePath).key) + const rootKey = localWatcherRoot(worktreePath).key + const suspended = suspendedLocalWatcherListeners.get(rootKey) + for (const senderId of suspended?.listeners.keys() ?? []) { + watcherAdmission.release(senderId, `local:${rootKey}`) + } + suspendedLocalWatcherListeners.delete(rootKey) } // Remote watcher state @@ -892,26 +935,11 @@ const suspendedRemoteWatcherListeners = new Map< string, { connectionId: string; worktreePath: string; listeners: Map } >() -// Why: the renderer subscribes once per target and never re-issues, so the intent to watch has to -// outlive any single connection — an install that failed or died with a dropped transport is -// re-armed from here when a provider appears. Without it a reconnect (or a connect slower than the -// retry window) leaves the watch dead until the app restarts. -const desiredRemoteWatchers = new Map< - string, - { connectionId: string; worktreePath: string; listeners: Map } ->() -// Why: provider registration only fires on reconnect, so a watch that dies while the SSH link stays -// healthy (remote OOM, inotify/fd exhaustion, relay watcher killed) has no re-arm trigger at all -// once the fast window gives up. Backoff keeps the recovery attempt without the 1s storm. -const dormantRemoteWatchers = new Map< - string, - { delayMs: number; timer: ReturnType } ->() const loggedUnavailableRemoteWatchers = new Set() const pendingRemoteWatcherRetries = new Map>() const pendingRemoteWatcherRetryListeners = new Map< string, - { listeners: Map; startedAt: number; resyncOnInstall: boolean } + { listeners: Map; startedAt: number } >() // Why: last-listener cleanup aborts relay setup; late success is unwatched rather than installed after the renderer stopped watching. const inFlightRemoteInstalls = new Map() @@ -921,13 +949,8 @@ const pendingRemoteInstallPromises = new Map void) | null = null const REMOTE_WATCH_RETRY_MS = 1_000 const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 -// Why: doubling from a minute to a half-hour ceiling costs a permanently broken remote ~7 fs.watch -// calls in the first hour and 2/hour after, which a flapping link can absorb. -const REMOTE_WATCH_DORMANT_RETRY_MS = 60_000 -const REMOTE_WATCH_DORMANT_RETRY_MAX_MS = 30 * 60_000 export async function closeRemoteWatcherForWorktreePath( connectionId: string, @@ -959,8 +982,6 @@ export async function closeRemoteWatcherForWorktreePath( pendingRemoteWatcherRetries.delete(key) pendingRemoteWatcherRetryListeners.delete(key) } - // Why: removal is deliberate — a backoff firing mid-removal would re-watch the path being deleted. - clearDormantRemoteWatcher(key) const inFlight = inFlightRemoteInstalls.get(key) if (inFlight) { inFlight.listeners.clear() @@ -1005,11 +1026,11 @@ export function forgetRemoteWatcherRemovalSnapshot( worktreePath: string ): void { const key = remoteWatcherKey(connectionId, worktreePath) + const suspended = suspendedRemoteWatcherListeners.get(key) + for (const senderId of suspended?.listeners.keys() ?? []) { + watcherAdmission.release(senderId, `remote:${key}`) + } suspendedRemoteWatcherListeners.delete(key) - // Why: the worktree is gone — keeping the intent lets a reconnect landing before the renderer's - // unwatch re-watch a deleted path (60s of retries against the host, then a bogus overflow). - desiredRemoteWatchers.delete(key) - clearDormantRemoteWatcher(key) } function addInFlightRemoteInstallListener( @@ -1080,9 +1101,6 @@ function releaseRemoteWatchListener(key: string, senderId: number): void { } function cleanupRemoteWatchersForSender(senderId: number): void { - for (const key of Array.from(desiredRemoteWatchers.keys())) { - forgetDesiredRemoteWatcher(key, senderId) - } for (const [key, suspended] of suspendedRemoteWatcherListeners) { suspended.listeners.delete(senderId) if (suspended.listeners.size === 0) { @@ -1282,10 +1300,7 @@ function scheduleRemoteWatcherRetry( sender: WebContents, connectionId: string, worktreePath: string, - startedAt = Date.now(), - // Why: a retry that replaces a watch which was already live owes the renderer an overflow once it - // lands — the events lost while it was down are otherwise never signalled. - resyncOnInstall = false + startedAt = Date.now() ): void { const key = remoteWatcherKey(connectionId, worktreePath) const existingRetry = pendingRemoteWatcherRetryListeners.get(key) @@ -1293,14 +1308,12 @@ function scheduleRemoteWatcherRetry( if (!sender.isDestroyed()) { existingRetry.listeners.set(sender.id, sender) } - existingRetry.resyncOnInstall ||= resyncOnInstall return } const retry = { listeners: new Map(sender.isDestroyed() ? [] : [[sender.id, sender]]), - startedAt, - resyncOnInstall + startedAt } pendingRemoteWatcherRetryListeners.set(key, retry) @@ -1321,8 +1334,6 @@ function scheduleRemoteWatcherRetry( events: [{ kind: 'overflow', absolutePath: worktreePath }] } satisfies FsChangedPayload) } - // Why: overflow only refreshes once — without this the watch stays dead until the app restarts. - scheduleDormantRemoteWatcherRearm(connectionId, worktreePath) return } @@ -1332,30 +1343,14 @@ function scheduleRemoteWatcherRetry( const listeners = Array.from(retry.listeners.values()).filter( (listener) => !listener.isDestroyed() ) - void Promise.all( - listeners.map((listener) => installRemoteWatcher(listener, connectionId, worktreePath)) + void mapWithConcurrency(listeners, WATCHER_LISTENER_INSTALL_CONCURRENCY, (listener) => + installRemoteWatcher(listener, connectionId, worktreePath) ) .then((results) => { - if (retry.resyncOnInstall) { - for (const [index, listener] of listeners.entries()) { - if (results[index] === 'installed' && !listener.isDestroyed()) { - listener.send('fs:changed', { - worktreePath, - events: [{ kind: 'overflow', absolutePath: worktreePath }] - } satisfies FsChangedPayload) - } - } - } // Why: don't re-arm on 'cancelled' (renderer stopped watching) — it would fire a stale overflow when the 60s window expires. if (results.some((result) => result === 'unavailable')) { for (const listener of listeners) { - scheduleRemoteWatcherRetry( - listener, - connectionId, - worktreePath, - retry.startedAt, - retry.resyncOnInstall - ) + scheduleRemoteWatcherRetry(listener, connectionId, worktreePath, retry.startedAt) } } }) @@ -1364,13 +1359,7 @@ function scheduleRemoteWatcherRetry( return } for (const listener of listeners) { - scheduleRemoteWatcherRetry( - listener, - connectionId, - worktreePath, - retry.startedAt, - retry.resyncOnInstall - ) + scheduleRemoteWatcherRetry(listener, connectionId, worktreePath, retry.startedAt) } }) }, REMOTE_WATCH_RETRY_MS) @@ -1380,23 +1369,18 @@ function scheduleRemoteWatcherRetry( // ── Public API ─────────────────────────────────────────────────────── export function registerFilesystemWatcherHandlers(): void { - // Why: re-registration replaces the handler set, so drop the previous subscription instead of - // stacking a second re-arm on every provider registration. - unsubscribeFromProviderRegistrations?.() - unsubscribeFromProviderRegistrations = onSshFilesystemProviderRegistered( - reinstallRemoteWatchersForConnection - ) - - ipcMain.handle( - 'fs:watchWorktree', - async (event, args: { worktreePath: string; connectionId?: string }): Promise => { + ipcMain.handle('fs:watchWorktree', async (event, rawArgs: unknown): Promise => { + const args = parseFilesystemWatcherIdentity(rawArgs) + const admission = watcherAdmission.claim( + event.sender.id, + watcherAdmissionKey(args), + args.retainedBytes + ) + try { if (args.connectionId) { // Why: a real new watch reopens the subsystem after closeAllWatchers latched it shut (also resets tests between cases). remoteWatchersClosed = false const key = remoteWatcherKey(args.connectionId, args.worktreePath) - // Why: record intent before the install so a provider registering mid-flight (or long after - // this attempt gives up) can still re-arm this listener. - rememberDesiredRemoteWatcher(args.connectionId, args.worktreePath, event.sender) const result = await installRemoteWatcher( event.sender, args.connectionId, @@ -1417,270 +1401,60 @@ export function registerFilesystemWatcherHandlers(): void { // Why: reopen the local subsystem for tests and post-shutdown reattachment; stale callers keep the prior generation. localWatchersClosed = false await subscribe(args.worktreePath, event.sender) - } - ) - - ipcMain.handle( - 'fs:unwatchWorktree', - (_event, args: { worktreePath: string; connectionId?: string }): void => { - if (args.connectionId) { - const key = remoteWatcherKey(args.connectionId, args.worktreePath) - // Why: the caller stopped watching on purpose — drop the intent or a later provider - // registration would resurrect a watch nobody asked for. - forgetDesiredRemoteWatcher(key, _event.sender.id) - const suspended = suspendedRemoteWatcherListeners.get(key) - suspended?.listeners.delete(_event.sender.id) - if (suspended?.listeners.size === 0) { - suspendedRemoteWatcherListeners.delete(key) + if (admission.added) { + const rootKey = localWatcherRoot(args.worktreePath).key + if (!hasRetainedLocalWatcherListener(rootKey, event.sender.id)) { + admission.release() } - const retry = pendingRemoteWatcherRetryListeners.get(key) - retry?.listeners.delete(_event.sender.id) - const retryTimer = pendingRemoteWatcherRetries.get(key) - if (retryTimer && retry?.listeners.size === 0) { - clearTimeout(retryTimer) - pendingRemoteWatcherRetries.delete(key) - pendingRemoteWatcherRetryListeners.delete(key) - } - // Why: a retry-tick provider.watch() may still be in flight; mark cancelled so its resolved unwatch handle is discarded. - const inFlight = inFlightRemoteInstalls.get(key) - if (inFlight) { - inFlight.listeners.delete(_event.sender.id) - cancelInFlightRemoteInstallIfUnowned(inFlight) - } - loggedUnavailableRemoteWatchers.delete(key) - releaseRemoteWatchListener(key, _event?.sender?.id ?? 0) - return } - const senderId = _event.sender.id - unsubscribe(args.worktreePath, senderId) + } catch (error) { + if (admission.added) { + admission.release() + } + throw error } - ) + }) + + ipcMain.handle('fs:unwatchWorktree', (_event, rawArgs: unknown): void => { + const args = parseFilesystemWatcherIdentity(rawArgs) + watcherAdmission.release(_event.sender.id, watcherAdmissionKey(args)) + if (args.connectionId) { + const key = remoteWatcherKey(args.connectionId, args.worktreePath) + const suspended = suspendedRemoteWatcherListeners.get(key) + suspended?.listeners.delete(_event.sender.id) + if (suspended?.listeners.size === 0) { + suspendedRemoteWatcherListeners.delete(key) + } + const retry = pendingRemoteWatcherRetryListeners.get(key) + retry?.listeners.delete(_event.sender.id) + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer && retry?.listeners.size === 0) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + pendingRemoteWatcherRetryListeners.delete(key) + } + // Why: a retry-tick provider.watch() may still be in flight; mark cancelled so its resolved unwatch handle is discarded. + const inFlight = inFlightRemoteInstalls.get(key) + if (inFlight) { + inFlight.listeners.delete(_event.sender.id) + cancelInFlightRemoteInstallIfUnowned(inFlight) + } + loggedUnavailableRemoteWatchers.delete(key) + releaseRemoteWatchListener(key, _event?.sender?.id ?? 0) + return + } + const senderId = _event.sender.id + unsubscribe(args.worktreePath, senderId) + }) } function remoteWatcherKey(connectionId: string, worktreePath: string): string { return JSON.stringify([connectionId, normalizeRuntimePathForComparison(worktreePath)]) } -function rememberDesiredRemoteWatcher( - connectionId: string, - worktreePath: string, - sender: WebContents -): void { - if (sender.isDestroyed()) { - return - } - const key = remoteWatcherKey(connectionId, worktreePath) - const desired = desiredRemoteWatchers.get(key) ?? { - connectionId, - worktreePath, - listeners: new Map() - } - desired.listeners.set(sender.id, sender) - desiredRemoteWatchers.set(key, desired) - registerSenderCleanup(sender) -} - -function forgetDesiredRemoteWatcher(key: string, senderId: number): void { - const desired = desiredRemoteWatchers.get(key) - if (!desired) { - return - } - desired.listeners.delete(senderId) - if (desired.listeners.size === 0) { - desiredRemoteWatchers.delete(key) - clearDormantRemoteWatcher(key) - } -} - -function clearDormantRemoteWatcher(key: string): void { - const dormant = dormantRemoteWatchers.get(key) - if (!dormant) { - return - } - clearTimeout(dormant.timer) - dormantRemoteWatchers.delete(key) -} - -function scheduleDormantRemoteWatcherRearm( - connectionId: string, - worktreePath: string, - delayMs = REMOTE_WATCH_DORMANT_RETRY_MS -): void { - const key = remoteWatcherKey(connectionId, worktreePath) - if (remoteWatchersClosed || !desiredRemoteWatchers.has(key) || dormantRemoteWatchers.has(key)) { - return - } - const timer = setTimeout(() => { - dormantRemoteWatchers.delete(key) - void rearmDormantRemoteWatcher(key, connectionId, worktreePath, delayMs) - }, delayMs) - // Why: a half-hour timer shouldn't be what keeps the process alive at quit. - timer.unref?.() - dormantRemoteWatchers.set(key, { delayMs, timer }) -} - -async function rearmDormantRemoteWatcher( - key: string, - connectionId: string, - worktreePath: string, - delayMs: number -): Promise { - const desired = desiredRemoteWatchers.get(key) - if (remoteWatchersClosed || !desired) { - return - } - for (const [senderId, sender] of Array.from(desired.listeners)) { - if (sender.isDestroyed()) { - desired.listeners.delete(senderId) - } - } - if (desired.listeners.size === 0) { - desiredRemoteWatchers.delete(key) - return - } - // Why: a live watch or an in-flight fast retry already owns this key; installing again would - // clobber the entry the running watch reads its listeners from. - if (remoteWatchers.has(key) || pendingRemoteWatcherRetries.has(key)) { - return - } - // Why: no provider means the connection itself is down, and its registration re-arms for free — - // polling would only add wire traffic to a link that is already being rebuilt. - if (!getSshFilesystemProvider(connectionId)) { - return - } - - const listeners = Array.from(desired.listeners.values()) - let results: RemoteWatcherInstallResult[] - try { - results = await Promise.all( - listeners.map((listener) => installRemoteWatcher(listener, connectionId, worktreePath)) - ) - } catch (error) { - if (isWatcherRemovalInProgressError(error)) { - // Why: removal owns the key now and either forgets the intent or restores the watch itself. - return - } - scheduleDormantRemoteWatcherRearm(connectionId, worktreePath, nextDormantDelayMs(delayMs)) - return - } - for (const [index, listener] of listeners.entries()) { - if (results[index] !== 'installed' || listener.isDestroyed()) { - continue - } - listener.send('fs:changed', { - worktreePath, - events: [{ kind: 'overflow', absolutePath: worktreePath }] - } satisfies FsChangedPayload) - } - // Why: 'cancelled' means shutdown or the last listener left, so only 'unavailable' stays dormant. - if (results.some((result) => result === 'unavailable')) { - scheduleDormantRemoteWatcherRearm(connectionId, worktreePath, nextDormantDelayMs(delayMs)) - } -} - -function nextDormantDelayMs(delayMs: number): number { - return Math.min(delayMs * 2, REMOTE_WATCH_DORMANT_RETRY_MAX_MS) -} - -/** - * Rebuild remote watches for a connection whose filesystem provider was just (re)registered. - * - * Why: the relay's watch registrations die with the transport they were made on, and the previous - * provider's unwatch handle is scoped to that dead transport. Reinstalling is the only way the - * subscription comes back, and consumers get an overflow so they resync whatever changed while the - * watch was down. - */ -function reinstallRemoteWatchersForConnection(connectionId: string): void { - if (remoteWatchersClosed) { - return - } - for (const [key, desired] of Array.from(desiredRemoteWatchers)) { - if (desired.connectionId !== connectionId) { - continue - } - for (const [senderId, sender] of Array.from(desired.listeners)) { - if (sender.isDestroyed()) { - desired.listeners.delete(senderId) - } - } - if (desired.listeners.size === 0) { - desiredRemoteWatchers.delete(key) - continue - } - - // Why: drop the entry the dead transport left behind first — installRemoteWatcher treats an - // existing entry as already-installed and would hand back a watcher that can never fire again. - const stale = remoteWatchers.get(key) - if (stale) { - remoteWatchers.delete(key) - try { - stale.unwatch() - } catch { - // Why: the handle belongs to the replaced transport; failing to close it is expected. - } - } - const retryTimer = pendingRemoteWatcherRetries.get(key) - if (retryTimer) { - clearTimeout(retryTimer) - pendingRemoteWatcherRetries.delete(key) - pendingRemoteWatcherRetryListeners.delete(key) - } - // Why: this reinstall supersedes the pending backoff; leaving it armed double-installs the key. - clearDormantRemoteWatcher(key) - loggedUnavailableRemoteWatchers.delete(key) - - const listeners = Array.from(desired.listeners.values()) - void Promise.all( - listeners.map((listener) => - installRemoteWatcher(listener, desired.connectionId, desired.worktreePath) - ) - ) - .then((results) => { - for (const [index, listener] of listeners.entries()) { - if (results[index] !== 'installed' || listener.isDestroyed()) { - continue - } - // Why: events between the transport dropping and this reinstall are gone for good; - // overflow is the existing "resync, I can't tell you what changed" signal. - listener.send('fs:changed', { - worktreePath: desired.worktreePath, - events: [{ kind: 'overflow', absolutePath: desired.worktreePath }] - } satisfies FsChangedPayload) - } - if (results.some((result) => result === 'unavailable')) { - for (const listener of listeners) { - scheduleRemoteWatcherRetry( - listener, - desired.connectionId, - desired.worktreePath, - Date.now(), - true - ) - } - } - }) - .catch((error: unknown) => { - if (isWatcherRemovalInProgressError(error)) { - return - } - for (const listener of listeners) { - scheduleRemoteWatcherRetry( - listener, - desired.connectionId, - desired.worktreePath, - Date.now(), - true - ) - } - }) - } -} - /** Tear down all watchers on app shutdown. */ export async function closeAllWatchers(): Promise { - // Why: drop the intent with the rest of the state, but keep the provider-registration - // subscription — a new fs:watchWorktree reopens the subsystem and still needs the re-arm hook. - desiredRemoteWatchers.clear() + watcherAdmission.clear() senderCleanupRegistered.clear() unwatchableRoots.clear() suspendedLocalWatcherListeners.clear() @@ -1701,10 +1475,6 @@ export async function closeAllWatchers(): Promise { } pendingRemoteWatcherRetries.clear() pendingRemoteWatcherRetryListeners.clear() - for (const dormant of dormantRemoteWatchers.values()) { - clearTimeout(dormant.timer) - } - dormantRemoteWatchers.clear() loggedUnavailableRemoteWatchers.clear() // Why: latch both subsystems shut so late installs can't register; generation bumps reject older-lifecycle waiters. remoteWatchersClosed = true diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 2f76c80791b..d2b2c652815 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -11,7 +11,7 @@ const { fromWebContentsMock, trashItemMock, readdirMock, - readFileMock, + opendirMock, writeFileMock, statMock, openMock, @@ -42,6 +42,8 @@ const { discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, cancelGeneratePullRequestFieldsLocalMock, + getPullRequestDraftContextMock, + resolveHostedReviewBodyForGenerationMock, getSshFilesystemProviderMock, getSshGitProviderMock, tryDeleteWslUncPathMock, @@ -54,7 +56,7 @@ const { fromWebContentsMock: vi.fn(), trashItemMock: vi.fn(), readdirMock: vi.fn(), - readFileMock: vi.fn(), + opendirMock: vi.fn(), writeFileMock: vi.fn(), statMock: vi.fn(), openMock: vi.fn(), @@ -85,6 +87,8 @@ const { discoverCommitMessageModelsRemoteMock: vi.fn(), cancelGenerateCommitMessageLocalMock: vi.fn(), cancelGeneratePullRequestFieldsLocalMock: vi.fn(), + getPullRequestDraftContextMock: vi.fn(), + resolveHostedReviewBodyForGenerationMock: vi.fn(), getSshFilesystemProviderMock: vi.fn(), getSshGitProviderMock: vi.fn(), tryDeleteWslUncPathMock: vi.fn(), @@ -110,7 +114,7 @@ vi.mock('electron', () => ({ vi.mock('fs/promises', () => ({ readdir: readdirMock, - readFile: readFileMock, + opendir: opendirMock, writeFile: writeFileMock, stat: statMock, open: openMock, @@ -189,6 +193,16 @@ vi.mock('../text-generation/commit-message-text-generation', () => ({ cancelGeneratePullRequestFieldsLocal: cancelGeneratePullRequestFieldsLocalMock })) +vi.mock('../text-generation/pull-request-context', () => ({ + getPullRequestDraftContext: getPullRequestDraftContextMock +})) + +vi.mock('../source-control/pull-request-template', () => ({ + readHostedPullRequestTemplate: vi.fn(), + readHostedReviewTemplate: vi.fn(), + resolveHostedReviewBodyForGeneration: resolveHostedReviewBodyForGenerationMock +})) + import { registerFilesystemHandlers } from './filesystem' import { invalidateAuthorizedRootsCache, registerWorktreeRootsForRepo } from './filesystem-auth' @@ -219,6 +233,33 @@ function dirEntry({ name, directory, file, symlink }: MockDirEntry): { } } +function mockReadableFile( + content: Buffer, + stats: { dev?: number; ino?: number; birthtimeMs?: number } = {} +) { + const handle = { + stat: vi.fn().mockResolvedValue({ size: content.byteLength, ...stats }), + read: vi.fn(async (target: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min(length, Math.max(0, content.byteLength - position)) + content.copy(target, offset, position, position + bytesRead) + return { bytesRead, buffer: target } + }), + close: vi.fn().mockResolvedValue(undefined) + } + openMock.mockResolvedValue(handle) + return handle +} + +function pngHeader(width = 1, height = 1): Buffer { + const bytes = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) + bytes.writeUInt32BE(13, 8) + bytes.write('IHDR', 12, 'ascii') + bytes.writeUInt32BE(width, 16) + bytes.writeUInt32BE(height, 20) + return bytes +} + async function withPlatform(platform: NodeJS.Platform, run: () => Promise): Promise { const original = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: platform }) @@ -262,7 +303,7 @@ describe('registerFilesystemHandlers', () => { fromWebContentsMock, trashItemMock, readdirMock, - readFileMock, + opendirMock, writeFileMock, statMock, openMock, @@ -289,6 +330,8 @@ describe('registerFilesystemHandlers', () => { resolveCommitMessageSettingsMock, generateCommitMessageFromContextMock, generatePullRequestFieldsFromContextMock, + getPullRequestDraftContextMock, + resolveHostedReviewBodyForGenerationMock, discoverCommitMessageModelsLocalMock, discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, @@ -340,6 +383,14 @@ describe('registerFilesystemHandlers', () => { close: vi.fn() }) lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + opendirMock.mockImplementation(async (dirPath: string) => { + const entries = await readdirMock(dirPath, { withFileTypes: true }) + return { + async *[Symbol.asyncIterator]() { + yield* entries + } + } + }) }) it('returns an actionable reconnect error when the SSH filesystem provider is unavailable', async () => { @@ -986,7 +1037,7 @@ describe('registerFilesystemHandlers', () => { 'Access denied: path resolves outside allowed directories' ) - expect(readFileMock).not.toHaveBeenCalled() + expect(openMock).not.toHaveBeenCalled() }) it('allows readDir when a registered worktree resolves to a macOS canonical alias', async () => { @@ -1105,7 +1156,7 @@ describe('registerFilesystemHandlers', () => { 'Access denied: path resolves outside allowed directories' ) - expect(readFileMock).not.toHaveBeenCalled() + expect(openMock).not.toHaveBeenCalled() }) it('does not enumerate worktrees when filesystem handlers register', () => { @@ -1148,32 +1199,56 @@ describe('registerFilesystemHandlers', () => { ) it.each([ - { ext: 'png', mime: 'image/png', data: [0x89, 0x50, 0x4e, 0x47, 0x00] }, + { + ext: 'png', + mime: 'image/png', + data: Array.from(pngHeader()), + imageDimensions: { width: 1, height: 1 } + }, { ext: 'pdf', mime: 'application/pdf', data: [0x25, 0x50, 0x44, 0x46, 0x00] }, { ext: 'svg', mime: 'image/svg+xml', data: Array.from(Buffer.from('')) } - ])('returns base64 content for supported $ext binaries', async ({ ext, mime, data }) => { - const buf = Buffer.from(data) + ])( + 'returns base64 content for supported $ext binaries', + async ({ ext, mime, data, imageDimensions }) => { + const buf = Buffer.from(data) + statMock.mockResolvedValue({ size: buf.length, isDirectory: () => false, mtimeMs: 123 }) + mockReadableFile(buf) + registerFilesystemHandlers(store as never) + await expect( + handlers.get('fs:readFile')!(null, { + filePath: path.resolve(`/workspace/repo/file.${ext}`) + }) + ).resolves.toEqual({ + content: buf.toString('base64'), + isBinary: true, + isImage: true, + mimeType: mime, + ...(imageDimensions ? { imageDimensions } : {}) + }) + } + ) + + it('rejects a raster dimension bomb before returning base64 to the renderer', async () => { + const buf = pngHeader(32_769, 1) statMock.mockResolvedValue({ size: buf.length, isDirectory: () => false, mtimeMs: 123 }) - readFileMock.mockResolvedValue(buf) + mockReadableFile(buf) registerFilesystemHandlers(store as never) + await expect( - handlers.get('fs:readFile')!(null, { filePath: path.resolve(`/workspace/repo/file.${ext}`) }) - ).resolves.toEqual({ - content: buf.toString('base64'), - isBinary: true, - isImage: true, - mimeType: mime - }) + handlers.get('fs:readFile')!(null, { + filePath: path.resolve('/workspace/repo/bomb.png') + }) + ).rejects.toThrow('Image dimensions exceed the preview safety limit') }) it('opens text files larger than the old 5MB guard', async () => { const content = 'a'.repeat(6 * 1024 * 1024) statMock.mockResolvedValue({ size: content.length, isDirectory: () => false, mtimeMs: 123 }) - readFileMock.mockResolvedValue(Buffer.from(content)) + mockReadableFile(Buffer.from(content)) registerFilesystemHandlers(store as never) @@ -1187,17 +1262,7 @@ describe('registerFilesystemHandlers', () => { it('returns stable byte metadata only for opted-in local log snapshots', async () => { const content = Buffer.from('first\npartial') - const close = vi.fn() - openMock.mockResolvedValue({ - stat: vi.fn().mockResolvedValue({ - size: content.byteLength, - dev: 1, - ino: 2, - birthtimeMs: 3 - }), - readFile: vi.fn().mockResolvedValue(content), - close - }) + const handle = mockReadableFile(content, { dev: 1, ino: 2, birthtimeMs: 3 }) registerFilesystemHandlers(store as never) await expect( @@ -1210,8 +1275,7 @@ describe('registerFilesystemHandlers', () => { isBinary: false, fileIdentity: '1:2:3' }) - expect(close).toHaveBeenCalledTimes(1) - expect(readFileMock).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledTimes(1) }) it('rejects text files beyond the editor read budget', async () => { @@ -1223,7 +1287,7 @@ describe('registerFilesystemHandlers', () => { handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/huge.json') }) ).rejects.toThrow('exceeds 50MB limit') - expect(readFileMock).not.toHaveBeenCalled() + expect(openMock).not.toHaveBeenCalled() }) it('probes large unknown binaries without reading the full file', async () => { @@ -1245,7 +1309,7 @@ describe('registerFilesystemHandlers', () => { isBinary: true }) - expect(readFileMock).not.toHaveBeenCalled() + expect(openMock).toHaveBeenCalledTimes(1) }) it('moves files to trash', async () => { @@ -1316,7 +1380,7 @@ describe('registerFilesystemHandlers', () => { it('keeps non-image binaries hidden from the editor payload', async () => { statMock.mockResolvedValue({ size: 4, isDirectory: () => false, mtimeMs: 123 }) - readFileMock.mockResolvedValue(Buffer.from([0x00, 0x01, 0x02])) + mockReadableFile(Buffer.from([0x00, 0x01, 0x02])) registerFilesystemHandlers(store as never) @@ -1777,7 +1841,7 @@ describe('registerFilesystemHandlers', () => { it('lists remote markdown documents through the SSH filesystem provider', async () => { const provider = { - listFiles: vi + listMarkdownDocuments: vi .fn() .mockResolvedValue(['README.md', 'docs/guide.mdx', '../outside.md', 'src/app.ts']) } @@ -1804,6 +1868,7 @@ describe('registerFilesystemHandlers', () => { name: 'README' } ]) + expect(provider.listMarkdownDocuments).toHaveBeenCalledWith('/home/user/project') }) it('routes branch compare queries through the git compare helper', async () => { @@ -2056,6 +2121,220 @@ describe('registerFilesystemHandlers', () => { }) }) + it('enriches the local commit context with a validated worktree linked issue', async () => { + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + const worktreeId = `repo-1::${WORKTREE_FEATURE_PATH}` + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Update' }) + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 123 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId + }) + + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + { ...context, linkedIssue: 123 }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + // Why: folder-repo instances keep `::workspace:` on the meta key while the + // request path is the stripped cwd. A strip-before-lookup "cleanup" would still + // pass plain-id tests and silently lose enrichment on second workspaces. + it('enriches local commit context when the worktree id carries a folder-repo workspace suffix', async () => { + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + const instanceId = `repo-1::${WORKTREE_FEATURE_PATH}::workspace:${'0'.repeat(8)}-0000-0000-0000-${'0'.repeat(12)}` + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Update' }) + const getWorktreeMeta = vi.fn((id: string) => + id === instanceId ? { linkedIssue: 9 } : undefined + ) + + registerFilesystemHandlers({ ...store, getWorktreeMeta } as never) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId: instanceId + }) + + expect(getWorktreeMeta).toHaveBeenCalledWith(instanceId) + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + { ...context, linkedIssue: 9 }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + // Why: the renderer derives worktreePath from worktreeId, so a mismatched pair + // models an independent caller (relay/CLI/future), not a stale renderer context. + it('ignores an independently supplied id that does not own the requested worktree path', async () => { + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + const getWorktreeMeta = vi.fn(() => ({ linkedIssue: 123 })) + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Update' }) + + registerFilesystemHandlers({ ...store, getWorktreeMeta } as never) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId: `repo-1::${path.resolve('/workspace/repo-other')}` + }) + + expect(getWorktreeMeta).not.toHaveBeenCalled() + // Why: without this the assertion below passes vacuously on an early return. + expect(generateCommitMessageFromContextMock.mock.calls).toHaveLength(1) + expect(generateCommitMessageFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty( + 'linkedIssue' + ) + }) + + it('enriches the SSH commit context from host meta using the remote path', async () => { + const context = { branch: 'main', stagedSummary: 'A\tremote.txt', stagedPatch: '+remote' } + const params = { agentId: 'custom', model: '', customAgentCommand: 'agent' } + const worktreeId = 'repo-1::/remote/repo' + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getSshGitProviderMock.mockReturnValue({ + getStagedCommitContext: vi.fn().mockResolvedValue(context), + executeCommitMessagePlan: vi.fn() + }) + generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Add file' }) + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: '/remote/repo', + worktreeId, + connectionId: 'conn-1' + }) + + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + { ...context, linkedIssue: 77 }, + params, + expect.objectContaining({ kind: 'remote' }) + ) + }) + + describe('git:generatePullRequestFields linked issue', () => { + const PULL_REQUEST_CONTEXT = { + base: 'main', + branch: 'feature/ai', + branchChangedByPreparation: false, + commitSummary: 'a1b2c3d Add generation', + changeSummary: 'README.md | 2 +-', + patch: '+hello', + currentTitle: '', + currentBody: '', + currentDraft: false + } + const PULL_REQUEST_ARGS = { base: 'main', title: '', body: '', draft: false } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + + beforeEach(() => { + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + resolveHostedReviewBodyForGenerationMock.mockResolvedValue('') + getPullRequestDraftContextMock.mockResolvedValue(PULL_REQUEST_CONTEXT) + generatePullRequestFieldsFromContextMock.mockResolvedValue({ success: true, fields: {} }) + }) + + it('enriches the local pull-request context with a validated worktree linked issue', async () => { + const worktreeId = `repo-1::${WORKTREE_FEATURE_PATH}` + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 123 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId + }) + + expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( + { ...PULL_REQUEST_CONTEXT, linkedIssue: 123 }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + it('enriches the SSH pull-request context from host meta using the remote path', async () => { + const worktreeId = 'repo-1::/remote/repo' + getSshGitProviderMock.mockReturnValue({ + exec: vi.fn(), + executeCommitMessagePlan: vi.fn() + }) + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: '/remote/repo', + worktreeId, + connectionId: 'conn-1' + }) + + expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( + { ...PULL_REQUEST_CONTEXT, linkedIssue: 77 }, + params, + expect.objectContaining({ kind: 'remote' }) + ) + }) + + it('ignores a pull-request worktree id that does not own the requested path', async () => { + const getWorktreeMeta = vi.fn(() => ({ linkedIssue: 123 })) + + registerFilesystemHandlers({ ...store, getWorktreeMeta } as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId: `repo-1::${path.resolve('/workspace/repo-other')}` + }) + + expect(getWorktreeMeta).not.toHaveBeenCalled() + // Why: without the length guard the property assertion passes vacuously on `undefined`, + // so an unrelated early return would read as "enrichment correctly suppressed". + expect(generatePullRequestFieldsFromContextMock.mock.calls).toHaveLength(1) + expect(generatePullRequestFieldsFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty( + 'linkedIssue' + ) + }) + }) + it('returns a sanitized error when local agent account preparation fails', async () => { const context = { branch: 'feature/ai', diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 414b67516f1..8f972987236 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,6 +1,6 @@ /* 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 { 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' @@ -39,6 +39,7 @@ import { ingestRgJsonLine, SEARCH_TIMEOUT_MS } from '../../shared/text-search' +import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' import { getStatus, getSubmoduleStatus, @@ -87,6 +88,7 @@ import { assertGitPushTargetShape } from '../../shared/git-push-target-validatio 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 { @@ -100,6 +102,7 @@ import { listQuickOpenFiles } from './filesystem-list-files' import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { searchWithGitGrep } from './filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' +import { resolveSourceControlAiLinkedIssue } from './source-control-ai-linked-issue' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' import { @@ -119,6 +122,7 @@ import { import { listRepoWorktrees } from '../repo-worktrees' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from './readdir-error-diagnostics' +import { readLocalFilesystemDirectory } from './filesystem-directory-reader' import { splitWorktreeId } from '../../shared/worktree-id' import { getRuntimePathBasename } from '../../shared/cross-platform-path' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' @@ -127,6 +131,8 @@ import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' import { sanitizeLocalDownloadFilename } from '../local-download-filename' import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { assertRasterImagePreviewWithinLimits } from '../../shared/raster-image-preview-limits' // 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 @@ -150,30 +156,14 @@ async function readLocalLogSnapshot(filePath: string): Promise<{ 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() + const { buffer, stats } = await readNodeFileWithinLimit(filePath, MAX_TEXT_FILE_SIZE) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) } } @@ -446,23 +436,6 @@ async function isBinaryFilePrefix(filePath: string): Promise { } } -async function isDirectoryEntry( - dirPath: string, - entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean }, - _resolveEntryPath: (entryPath: string) => Promise -): Promise { - // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. - void _resolveEntryPath - if (entry.isSymbolicLink()) { - void dirPath - return false - } - if (entry.isDirectory()) { - return true - } - return false -} - export function registerFilesystemHandlers( store: Store, commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers @@ -510,22 +483,7 @@ export function registerFilesystemHandlers( throwSite = 'authorize' const dirPath = await resolveAuthorizedPath(args.dirPath, store) throwSite = 'readdir' - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = await Promise.all( - entries.map(async (entry) => ({ - name: entry.name, - isDirectory: await isDirectoryEntry(dirPath, entry, (entryPath) => - resolveAuthorizedPath(entryPath, store) - ), - isSymlink: entry.isSymbolicLink() - })) - ) - return mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return await readLocalFilesystemDirectory(dirPath) } catch (error: unknown) { recordCrashBreadcrumb( 'fs_readdir_error', @@ -551,6 +509,7 @@ export function registerFilesystemHandlers( isBinary: boolean isImage?: boolean mimeType?: string + imageDimensions?: { width: number; height: number } fileIdentity?: string }> => { if (args.connectionId) { @@ -571,13 +530,15 @@ export function registerFilesystemHandlers( } if (mimeType) { - const buffer = await readFile(filePath) + const { buffer } = await readNodeFileWithinLimit(filePath, sizeLimit) + const imageDimensions = assertRasterImagePreviewWithinLimits(buffer, mimeType) 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 + mimeType, + ...(imageDimensions ? { imageDimensions } : {}) } } @@ -586,7 +547,7 @@ export function registerFilesystemHandlers( return { content: '', isBinary: true } } - const buffer = await readFile(filePath) + const { buffer } = await readNodeFileWithinLimit(filePath, sizeLimit) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } @@ -796,7 +757,12 @@ export function registerFilesystemHandlers( ): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) - const relativePaths = await provider.listFiles(args.rootPath) + if (!provider.listMarkdownDocuments) { + throw new Error( + 'Remote Markdown link discovery is unavailable. Reconnect the SSH target and retry.' + ) + } + const relativePaths = await provider.listMarkdownDocuments(args.rootPath) return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) } @@ -961,7 +927,7 @@ export function registerFilesystemHandlers( activeTextSearches.get(searchKey)?.kill() const acc = createAccumulator() - let stdoutBuffer = '' + const stdoutLines = new SearchSubprocessLineAccumulator() let resolved = false let child: ChildProcess | null = null let killTimeout: ReturnType @@ -1004,12 +970,11 @@ export function registerFilesystemHandlers( 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 handleStdoutData = (chunk: Buffer): void => { + if (!stdoutLines.push(chunk, processLine)) { + acc.truncated = true + child?.kill() + resolveOnce() } } const handleStderrData = (): void => { @@ -1019,13 +984,13 @@ export function registerFilesystemHandlers( resolveOnce() } const handleClose = (): void => { - if (stdoutBuffer) { - processLine(stdoutBuffer) + const trailingLine = stdoutLines.finish() + if (trailingLine !== null) { + processLine(trailingLine) } resolveOnce() } - nextChild.stdout!.setEncoding('utf-8') nextChild.stdout!.on('data', handleStdoutData) nextChild.stderr!.on('data', handleStderrData) nextChild.once('error', handleError) @@ -1360,6 +1325,8 @@ export function registerFilesystemHandlers( _event, args: { worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string repoId?: string connectionId?: string sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams @@ -1408,6 +1375,10 @@ export function registerFilesystemHandlers( 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, @@ -1435,6 +1406,10 @@ export function registerFilesystemHandlers( 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, @@ -1532,6 +1507,8 @@ export function registerFilesystemHandlers( _event, args: { worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string repoId?: string base: string title: string @@ -1601,6 +1578,10 @@ export function registerFilesystemHandlers( if (!context) { return { success: false, error: 'No branch changes to summarize.' } } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args) + ) return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: args.worktreePath, @@ -1644,6 +1625,10 @@ export function registerFilesystemHandlers( if (!context) { return { success: false, error: 'No branch changes to summarize.' } } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args, worktreePath) + ) const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, commitMessageAgentEnv, diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index a01225bfccf..531c87a026e 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -16,6 +16,7 @@ import type { PRRefreshOutcome } from '../../shared/types' import { getRepoExecutionHostId } from '../../shared/execution-host' +import { PR_REFRESH_VISIBLE_CANDIDATE_LIMIT } from '../../shared/pr-refresh-memory-limits' import type { TaskSourceContext } from '../../shared/task-source-context' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' @@ -360,6 +361,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi const candidates: GitHubPRRefreshCandidate[] = [] const repos = store.getRepos() for (const candidate of args.candidates) { + if (candidates.length >= PR_REFRESH_VISIBLE_CANDIDATE_LIMIT) { + break + } const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) if (validation.kind === 'ok') { candidates.push(validation.candidate) diff --git a/src/main/ipc/local-log-tail-operation-admission.ts b/src/main/ipc/local-log-tail-operation-admission.ts new file mode 100644 index 00000000000..adaf41b8db1 --- /dev/null +++ b/src/main/ipc/local-log-tail-operation-admission.ts @@ -0,0 +1,88 @@ +export const MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER = 32 +export const MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE = 128 +export const MAX_LOCAL_LOG_TAIL_READS_PER_SENDER = 16 +export const MAX_LOCAL_LOG_TAIL_READS_PROCESS_WIDE = 64 + +class ScopedLocalLogTailClaims { + private readonly claims = new Set() + private readonly claimsBySender = new Map>() + + constructor( + private readonly maxPerSender: number, + private readonly maxProcessWide: number, + private readonly label: string + ) {} + + claim(senderId: number, errorMessage: string): symbol { + const senderClaims = this.claimsBySender.get(senderId) + if ((senderClaims?.size ?? 0) >= this.maxPerSender || this.claims.size >= this.maxProcessWide) { + throw new Error(errorMessage) + } + const token = Symbol(this.label) + const nextSenderClaims = senderClaims ?? new Set() + nextSenderClaims.add(token) + this.claimsBySender.set(senderId, nextSenderClaims) + this.claims.add(token) + return token + } + + release(senderId: number, token: symbol): void { + this.claims.delete(token) + const senderClaims = this.claimsBySender.get(senderId) + senderClaims?.delete(token) + if (senderClaims?.size === 0) { + this.claimsBySender.delete(senderId) + } + } + + reset(): void { + this.claims.clear() + this.claimsBySender.clear() + } + + get size(): number { + return this.claims.size + } +} + +export class LocalLogTailOperationAdmission { + private readonly starts = new ScopedLocalLogTailClaims( + MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER, + MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE, + 'local-log-tail-start' + ) + private readonly reads = new ScopedLocalLogTailClaims( + MAX_LOCAL_LOG_TAIL_READS_PER_SENDER, + MAX_LOCAL_LOG_TAIL_READS_PROCESS_WIDE, + 'local-log-tail-read' + ) + + claimStart(senderId: number): symbol { + return this.starts.claim(senderId, 'Too many local log tail starts') + } + + releaseStart(senderId: number, token: symbol): void { + this.starts.release(senderId, token) + } + + claimRead(senderId: number): symbol { + return this.reads.claim(senderId, 'Too many concurrent local log tail reads') + } + + releaseRead(senderId: number, token: symbol): void { + this.reads.release(senderId, token) + } + + reset(): void { + this.starts.reset() + this.reads.reset() + } + + get pendingStartCount(): number { + return this.starts.size + } + + get pendingReadCount(): number { + return this.reads.size + } +} diff --git a/src/main/ipc/local-log-tail.test.ts b/src/main/ipc/local-log-tail.test.ts index beac464f4a4..ffa07812ab9 100644 --- a/src/main/ipc/local-log-tail.test.ts +++ b/src/main/ipc/local-log-tail.test.ts @@ -26,8 +26,18 @@ vi.mock('../ai-vault/local-log-tail-reader', () => ({ import { closeAllLocalLogTailWatchers, getActiveLocalLogTailWatcherCount, + getLocalLogTailSenderCleanupCountForTest, + getPendingLocalLogTailReadCountForTest, + getPendingLocalLogTailStartCountForTest, + MAX_LOCAL_LOG_TAIL_FILE_IDENTITY_BYTES, + MAX_LOCAL_LOG_TAIL_SUBSCRIPTION_ID_BYTES, registerLocalLogTailHandlers } from './local-log-tail' +import { + MAX_LOCAL_LOG_TAIL_READS_PER_SENDER, + MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER, + MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE +} from './local-log-tail-operation-admission' type FakeWatcher = { close: ReturnType @@ -50,19 +60,40 @@ function makeWatcher(): FakeWatcher { function makeSender(id: number) { let destroyedListener: (() => void) | undefined + let destroyed = false return { id, send: vi.fn(), - isDestroyed: vi.fn(() => false), + isDestroyed: vi.fn(() => destroyed), once: vi.fn((event: string, listener: () => void) => { if (event === 'destroyed') { destroyedListener = listener } }), - destroy: () => destroyedListener?.() + removeListener: vi.fn((event: string, listener: () => void) => { + if (event === 'destroyed' && destroyedListener === listener) { + destroyedListener = undefined + } + }), + destroy: () => { + destroyed = true + destroyedListener?.() + } } } +async function startWatch( + sender: ReturnType, + subscriptionId: string, + filePath = `/logs/${subscriptionId}.jsonl` +): Promise { + await handlers.get('fs:startLocalLogTail')?.({ sender }, { filePath, subscriptionId }) +} + +function stopWatch(sender: ReturnType, subscriptionId: string): void { + handlers.get('fs:stopLocalLogTail')?.({ sender }, { subscriptionId }) +} + beforeEach(() => { handlers.clear() watchMock.mockReset() @@ -102,6 +133,8 @@ describe('local log tail IPC', () => { handlers.get('fs:stopLocalLogTail')?.({ sender }, { subscriptionId: 'tail-1' }) expect(watcher.close).toHaveBeenCalledTimes(1) expect(getActiveLocalLogTailWatcherCount()).toBe(0) + expect(getLocalLogTailSenderCleanupCountForTest()).toBe(0) + expect(sender.removeListener).toHaveBeenCalledWith('destroyed', expect.any(Function)) }) it('closes every watcher owned by a destroyed renderer', async () => { @@ -123,5 +156,219 @@ describe('local log tail IPC', () => { expect(first.close).toHaveBeenCalledTimes(1) expect(second.close).toHaveBeenCalledTimes(1) expect(getActiveLocalLogTailWatcherCount()).toBe(0) + expect(getLocalLogTailSenderCleanupCountForTest()).toBe(0) + }) + + it('caps one renderer at the exact watcher limit and recovers after stop', async () => { + watchMock.mockImplementation(() => makeWatcher()) + const sender = makeSender(10) + for (let index = 0; index < MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER; index++) { + await startWatch(sender, `tail-${index}`) + } + + expect(getActiveLocalLogTailWatcherCount()).toBe(MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER) + await expect(startWatch(sender, 'one-over')).rejects.toThrow('Too many local log tail watchers') + expect(watchMock).toHaveBeenCalledTimes(MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER) + + stopWatch(sender, 'tail-0') + await startWatch(sender, 'after-stop') + expect(getActiveLocalLogTailWatcherCount()).toBe(MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER) + expect(getLocalLogTailSenderCleanupCountForTest()).toBe(1) + }) + + it('allows same-key replacement at process capacity while rejecting a new key', async () => { + watchMock.mockImplementation(() => makeWatcher()) + const senderCount = Math.ceil( + MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE / MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER + ) + const senders = Array.from({ length: senderCount + 1 }, (_, index) => makeSender(20 + index)) + for (let index = 0; index < MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE; index++) { + const sender = senders[Math.floor(index / MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER)] + await startWatch(sender, `global-${index}`) + } + + await expect(startWatch(senders.at(-1)!, 'global-one-over')).rejects.toThrow( + 'Too many local log tail watchers' + ) + await startWatch(senders[0], 'global-0', '/logs/replacement.jsonl') + + expect(getActiveLocalLogTailWatcherCount()).toBe(MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE) + expect(watchMock).toHaveBeenCalledTimes(MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE + 1) + }) + + it('bounds pending authorization attempts and rechecks destruction before watching', async () => { + let resolvePaths = (): void => {} + const pathGate = new Promise((resolve) => { + resolvePaths = resolve + }) + resolveAuthorizedPathMock.mockImplementation(async (path: string) => { + await pathGate + return path + }) + watchMock.mockImplementation(() => makeWatcher()) + const sender = makeSender(30) + const starts = Array.from({ length: MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER }, (_, index) => + startWatch(sender, `pending-${index}`) + ) + + await vi.waitFor(() => + expect(getPendingLocalLogTailStartCountForTest()).toBe(MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER) + ) + await expect(startWatch(sender, 'pending-one-over')).rejects.toThrow( + 'Too many local log tail starts' + ) + sender.destroy() + resolvePaths() + await Promise.all(starts) + + expect(watchMock).not.toHaveBeenCalled() + expect(getPendingLocalLogTailStartCountForTest()).toBe(0) + }) + + it('does not install a watcher after stop wins a pending authorization race', async () => { + let authorizePath = (): void => {} + resolveAuthorizedPathMock.mockImplementation( + (path: string) => + new Promise((resolve) => { + authorizePath = () => resolve(path) + }) + ) + watchMock.mockImplementation(() => makeWatcher()) + const sender = makeSender(31) + + const pendingStart = startWatch(sender, 'pending-stop') + await vi.waitFor(() => expect(getPendingLocalLogTailStartCountForTest()).toBe(1)) + stopWatch(sender, 'pending-stop') + authorizePath() + await pendingStart + + expect(watchMock).not.toHaveBeenCalled() + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + expect(getLocalLogTailSenderCleanupCountForTest()).toBe(0) + }) + + it('lets the newest same-key start win when authorizations resolve out of order', async () => { + const authorizations = new Map void>() + resolveAuthorizedPathMock.mockImplementation( + (path: string) => + new Promise((resolve) => { + authorizations.set(path, resolve) + }) + ) + watchMock.mockImplementation(() => makeWatcher()) + const sender = makeSender(32) + + const olderStart = startWatch(sender, 'same-key', '/logs/older.jsonl') + const newerStart = startWatch(sender, 'same-key', '/logs/newer.jsonl') + await vi.waitFor(() => expect(authorizations.size).toBe(2)) + authorizations.get('/logs/newer.jsonl')?.('/logs/newer.jsonl') + await newerStart + authorizations.get('/logs/older.jsonl')?.('/logs/older.jsonl') + await olderStart + + expect(watchMock).toHaveBeenCalledTimes(1) + expect(watchMock).toHaveBeenCalledWith('/logs/newer.jsonl', expect.any(Function)) + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + }) + + it('bounds concurrent ranged reads and releases admission after settlement', async () => { + let resolveReads!: (value: { + contentBase64: string + nextByteOffset: number + fileSize: number + fileIdentity: string + hasMore: boolean + reset: boolean + }) => void + const readGate = new Promise<{ + contentBase64: string + nextByteOffset: number + fileSize: number + fileIdentity: string + hasMore: boolean + reset: boolean + }>((resolve) => { + resolveReads = resolve + }) + readRangeMock.mockReturnValue(readGate) + const sender = makeSender(33) + const read = handlers.get('fs:readLocalLogTail')! + const reads = Array.from({ length: MAX_LOCAL_LOG_TAIL_READS_PER_SENDER }, () => + read({ sender }, { filePath: '/logs/session.jsonl', fromByteOffset: 0 }) + ) + + await vi.waitFor(() => + expect(getPendingLocalLogTailReadCountForTest()).toBe(MAX_LOCAL_LOG_TAIL_READS_PER_SENDER) + ) + await expect( + read({ sender }, { filePath: '/logs/session.jsonl', fromByteOffset: 0 }) + ).rejects.toThrow('Too many concurrent local log tail reads') + resolveReads({ + contentBase64: '', + nextByteOffset: 0, + fileSize: 0, + fileIdentity: 'identity', + hasMore: false, + reset: false + }) + await Promise.all(reads) + + expect(readRangeMock).toHaveBeenCalledTimes(MAX_LOCAL_LOG_TAIL_READS_PER_SENDER) + expect(getPendingLocalLogTailReadCountForTest()).toBe(0) + }) + + it('rejects an oversized UTF-8 subscription id before path authorization', async () => { + const sender = makeSender(34) + const oversizedId = '😀'.repeat(Math.floor(MAX_LOCAL_LOG_TAIL_SUBSCRIPTION_ID_BYTES / 4) + 1) + + await expect(startWatch(sender, oversizedId)).rejects.toThrow( + 'Invalid local log tail subscription id' + ) + expect(resolveAuthorizedPathMock).not.toHaveBeenCalled() + expect(watchMock).not.toHaveBeenCalled() + }) + + it('validates and snapshots ranged-read fields before path authorization', async () => { + let authorizePath = (): void => {} + resolveAuthorizedPathMock.mockImplementation( + (path: string) => + new Promise((resolve) => { + authorizePath = () => resolve(path) + }) + ) + readRangeMock.mockResolvedValue({ + contentBase64: '', + nextByteOffset: 7, + fileSize: 7, + fileIdentity: 'identity-before', + hasMore: false, + reset: false + }) + const sender = makeSender(35) + const args = { + filePath: '/logs/session.jsonl', + fromByteOffset: 7, + expectedIdentity: 'identity-before' + } + + const pendingRead = handlers.get('fs:readLocalLogTail')?.({ sender }, args) + args.fromByteOffset = 99 + args.expectedIdentity = 'identity-after' + authorizePath() + await pendingRead + + expect(readRangeMock).toHaveBeenCalledWith('/logs/session.jsonl', 7, 'identity-before') + + await expect( + handlers.get('fs:readLocalLogTail')?.( + { sender }, + { + filePath: '/logs/session.jsonl', + fromByteOffset: 0, + expectedIdentity: '😀'.repeat(Math.floor(MAX_LOCAL_LOG_TAIL_FILE_IDENTITY_BYTES / 4) + 1) + } + ) + ).rejects.toThrow('Invalid local log tail file identity') + expect(resolveAuthorizedPathMock).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts index 430882b4e07..1be4fcca8bd 100644 --- a/src/main/ipc/local-log-tail.ts +++ b/src/main/ipc/local-log-tail.ts @@ -9,58 +9,192 @@ import type { } from '../../shared/local-log-tail-types' import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' import { resolveAuthorizedPath } from './filesystem-auth' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' +import { + LocalLogTailOperationAdmission, + MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER, + MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE +} from './local-log-tail-operation-admission' type TailWatch = { + generation: symbol senderId: number watcher: FSWatcher } const tailWatches = new Map() -const senderCleanupRegistered = new Set() +const watchGenerations = new Map() +const senderCleanups = new Map void }>() +const operationAdmission = new LocalLogTailOperationAdmission() + +export const MAX_LOCAL_LOG_TAIL_SUBSCRIPTION_ID_BYTES = 1_024 +export const MAX_LOCAL_LOG_TAIL_PATH_BYTES = 64 * 1_024 +export const MAX_LOCAL_LOG_TAIL_FILE_IDENTITY_BYTES = 1_024 function watchKey(senderId: number, subscriptionId: string): string { return `${senderId}:${subscriptionId}` } -function closeWatch(key: string): void { +function closeWatchHandle(key: string, generation?: symbol): number | undefined { const subscription = tailWatches.get(key) - if (!subscription) { - return + if (!subscription || (generation !== undefined && subscription.generation !== generation)) { + return undefined } tailWatches.delete(key) subscription.watcher.close() + releaseSenderCleanupIfIdle(subscription.senderId) + return subscription.senderId +} + +function closeWatch(key: string): void { + const owner = watchGenerations.get(key) + watchGenerations.delete(key) + const senderId = closeWatchHandle(key) ?? owner?.senderId + if (senderId !== undefined) { + releaseSenderCleanupIfIdle(senderId) + } +} + +function closeWatchGeneration(key: string, generation: symbol): void { + const owner = watchGenerations.get(key) + if (owner?.generation === generation) { + watchGenerations.delete(key) + } + const senderId = closeWatchHandle(key, generation) ?? owner?.senderId + if (senderId !== undefined) { + releaseSenderCleanupIfIdle(senderId) + } } function closeSenderWatches(senderId: number): void { - senderCleanupRegistered.delete(senderId) - for (const [key, subscription] of tailWatches) { - if (subscription.senderId === senderId) { + releaseSenderCleanup(senderId) + for (const [key, owner] of watchGenerations) { + if (owner.senderId === senderId) { closeWatch(key) } } } function validateSubscriptionId(value: unknown): string { - if (typeof value !== 'string' || value.length === 0 || value.length > 200) { + if ( + typeof value !== 'string' || + value.length === 0 || + measureUtf8ByteLength(value, { + stopAfterBytes: MAX_LOCAL_LOG_TAIL_SUBSCRIPTION_ID_BYTES + }).exceededLimit + ) { throw new Error('Invalid local log tail subscription id') } return value } +function validateFilePath(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + measureUtf8ByteLength(value, { stopAfterBytes: MAX_LOCAL_LOG_TAIL_PATH_BYTES }).exceededLimit + ) { + throw new Error('Invalid local log tail path') + } + return value +} + +function validateExpectedIdentity(value: unknown): string | undefined { + if (value === undefined) { + return undefined + } + if ( + typeof value !== 'string' || + measureUtf8ByteLength(value, { + stopAfterBytes: MAX_LOCAL_LOG_TAIL_FILE_IDENTITY_BYTES + }).exceededLimit + ) { + throw new Error('Invalid local log tail file identity') + } + return value +} + function registerSenderCleanup(sender: WebContents): void { - if (senderCleanupRegistered.has(sender.id)) { + if (senderCleanups.has(sender.id)) { return } - senderCleanupRegistered.add(sender.id) - sender.once('destroyed', () => closeSenderWatches(sender.id)) + const onDestroyed = (): void => closeSenderWatches(sender.id) + senderCleanups.set(sender.id, { sender, onDestroyed }) + sender.once('destroyed', onDestroyed) +} + +function releaseSenderCleanup(senderId: number): void { + const cleanup = senderCleanups.get(senderId) + if (!cleanup) { + return + } + senderCleanups.delete(senderId) + cleanup.sender.removeListener('destroyed', cleanup.onDestroyed) +} + +function releaseSenderCleanupIfIdle(senderId: number): void { + for (const owner of watchGenerations.values()) { + if (owner.senderId === senderId) { + return + } + } + for (const subscription of tailWatches.values()) { + if (subscription.senderId === senderId) { + return + } + } + releaseSenderCleanup(senderId) +} + +function activeSenderWatchCount(senderId: number): number { + let count = 0 + for (const subscription of tailWatches.values()) { + if (subscription.senderId === senderId) { + count += 1 + } + } + return count +} + +function restorePreviousWatchGeneration( + key: string, + generation: symbol, + previousGeneration: symbol | undefined, + senderId: number +): void { + if (watchGenerations.get(key)?.generation !== generation) { + return + } + const previousWatch = tailWatches.get(key) + if (previousGeneration !== undefined && previousWatch?.generation === previousGeneration) { + watchGenerations.set(key, { generation: previousGeneration, senderId }) + } else { + watchGenerations.delete(key) + } +} + +function validateByteOffset(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error('Invalid local log tail byte offset') + } + return value as number } export function registerLocalLogTailHandlers(store: Store): void { ipcMain.handle( 'fs:readLocalLogTail', - async (_event, args: LocalLogTailReadArgs): Promise => { - const filePath = await resolveAuthorizedPath(args.filePath, store) - return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity) + async (event, args: LocalLogTailReadArgs): Promise => { + const senderId = event.sender.id + const requestedPath = validateFilePath(args.filePath) + const fromByteOffset = validateByteOffset(args.fromByteOffset) + const expectedIdentity = validateExpectedIdentity(args.expectedIdentity) + const readToken = operationAdmission.claimRead(senderId) + try { + const filePath = await resolveAuthorizedPath(requestedPath, store) + return await readLocalLogTailRange(filePath, fromByteOffset, expectedIdentity) + } finally { + operationAdmission.releaseRead(senderId, readToken) + } } ) @@ -68,26 +202,56 @@ export function registerLocalLogTailHandlers(store: Store): void { 'fs:startLocalLogTail', async (event, args: LocalLogTailWatchArgs): Promise => { const subscriptionId = validateSubscriptionId(args.subscriptionId) - const filePath = await resolveAuthorizedPath(args.filePath, store) - const key = watchKey(event.sender.id, subscriptionId) - closeWatch(key) - - const sendChange = (eventType: 'change' | 'rename'): void => { - if (!tailWatches.has(key) || event.sender.isDestroyed()) { + const requestedPath = validateFilePath(args.filePath) + const senderId = event.sender.id + const key = watchKey(senderId, subscriptionId) + const pendingToken = operationAdmission.claimStart(senderId) + const previousGeneration = watchGenerations.get(key)?.generation + const generation = Symbol(subscriptionId) + watchGenerations.set(key, { generation, senderId }) + registerSenderCleanup(event.sender) + let published = false + try { + const filePath = await resolveAuthorizedPath(requestedPath, store) + if (event.sender.isDestroyed() || watchGenerations.get(key)?.generation !== generation) { return } - const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } - event.sender.send('fs:localLogTailChanged', payload) + const replacing = tailWatches.has(key) + if ( + !replacing && + (activeSenderWatchCount(senderId) >= MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER || + tailWatches.size >= MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE) + ) { + throw new Error('Too many local log tail watchers') + } + closeWatchHandle(key) + + const sendChange = (eventType: 'change' | 'rename'): void => { + if (tailWatches.get(key)?.generation !== generation || event.sender.isDestroyed()) { + return + } + const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } + event.sender.send('fs:localLogTailChanged', payload) + } + const watcher = watch(filePath, (eventType) => sendChange(eventType)) + watcher.on('error', () => { + // Why: an error commonly accompanies rotation. Signal one final drain so + // the renderer can detect identity change, then release the dead handle. + sendChange('rename') + closeWatchGeneration(key, generation) + }) + tailWatches.set(key, { generation, senderId, watcher }) + published = true + if (event.sender.isDestroyed()) { + closeWatchGeneration(key, generation) + } + } finally { + if (!published) { + restorePreviousWatchGeneration(key, generation, previousGeneration, senderId) + releaseSenderCleanupIfIdle(senderId) + } + operationAdmission.releaseStart(senderId, pendingToken) } - const watcher = watch(filePath, (eventType) => sendChange(eventType)) - watcher.on('error', () => { - // Why: an error commonly accompanies rotation. Signal one final drain so - // the renderer can detect identity change, then release the dead handle. - sendChange('rename') - closeWatch(key) - }) - tailWatches.set(key, { senderId: event.sender.id, watcher }) - registerSenderCleanup(event.sender) } ) @@ -97,13 +261,28 @@ export function registerLocalLogTailHandlers(store: Store): void { } export function closeAllLocalLogTailWatchers(): void { - for (const key of Array.from(tailWatches.keys())) { + for (const key of new Set([...watchGenerations.keys(), ...tailWatches.keys()])) { closeWatch(key) } - senderCleanupRegistered.clear() + for (const senderId of Array.from(senderCleanups.keys())) { + releaseSenderCleanup(senderId) + } + operationAdmission.reset() } /** Test-only: verifies tab/window teardown does not retain native watchers. */ export function getActiveLocalLogTailWatcherCount(): number { return tailWatches.size } + +export function getLocalLogTailSenderCleanupCountForTest(): number { + return senderCleanups.size +} + +export function getPendingLocalLogTailStartCountForTest(): number { + return operationAdmission.pendingStartCount +} + +export function getPendingLocalLogTailReadCountForTest(): number { + return operationAdmission.pendingReadCount +} diff --git a/src/main/ipc/markdown-documents.test.ts b/src/main/ipc/markdown-documents.test.ts index b23f5748cb4..b500acb3c14 100644 --- a/src/main/ipc/markdown-documents.test.ts +++ b/src/main/ipc/markdown-documents.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { markdownDocumentFromFilePath } from './markdown-documents' +import { + markdownDocumentFromFilePath, + markdownDocumentsFromRelativePaths +} from './markdown-documents' +import { MarkdownDocumentListingCapacityError } from '../../shared/markdown-document-listing-limits' describe('markdownDocumentFromFilePath', () => { it('keeps in-root path segments that merely start with parent traversal text', () => { @@ -24,3 +28,52 @@ describe('markdownDocumentFromFilePath', () => { }) }) }) + +describe('markdownDocumentsFromRelativePaths', () => { + it('preserves filtering and sorted output below every limit', () => { + expect( + markdownDocumentsFromRelativePaths('/workspace', [ + 'z-last.markdown', + 'src/app.ts', + '../outside.md', + 'docs/Guide.MDX', + 'README.md' + ]) + ).toEqual([ + { + filePath: '/workspace/docs/Guide.MDX', + relativePath: 'docs/Guide.MDX', + basename: 'Guide.MDX', + name: 'Guide' + }, + { + filePath: '/workspace/README.md', + relativePath: 'README.md', + basename: 'README.md', + name: 'README' + }, + { + filePath: '/workspace/z-last.markdown', + relativePath: 'z-last.markdown', + basename: 'z-last.markdown', + name: 'z-last' + } + ]) + }) + + it('rejects count, metadata, and UTF-8 path overflow before retaining another result', () => { + expect(() => + markdownDocumentsFromRelativePaths('/workspace', ['one.md', 'two.md'], { + maxDocuments: 1 + }) + ).toThrow(MarkdownDocumentListingCapacityError) + expect(() => + markdownDocumentsFromRelativePaths('/workspace', ['metadata.md'], { + maxMetadataBytes: 1 + }) + ).toThrow(MarkdownDocumentListingCapacityError) + expect(() => + markdownDocumentsFromRelativePaths('/workspace', [`${'é'.repeat(40_000)}.md`]) + ).toThrow(MarkdownDocumentListingCapacityError) + }) +}) diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts index 713adc9e0fb..687f50e1c5e 100644 --- a/src/main/ipc/markdown-documents.ts +++ b/src/main/ipc/markdown-documents.ts @@ -1,14 +1,22 @@ -import { readdir } from 'node:fs/promises' -import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path' +import { basename as pathBasename, extname, isAbsolute, relative, resolve } from 'node:path' import type { MarkdownDocument } from '../../shared/types' +import { + assertMarkdownDocumentPathWithinLimit, + createMarkdownDocumentListingBudget, + retainMarkdownDocument, + type MarkdownDocumentListingLimits +} from '../../shared/markdown-document-listing-limits' +import { + discoverMarkdownRelativePaths, + isMarkdownDocumentPath +} from '../../shared/node-markdown-document-discovery' function normalizeRelativePath(path: string): string { return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') } export function isMarkdownDocumentName(name: string): boolean { - const extension = extname(name).toLowerCase() - return extension === '.md' || extension === '.mdx' || extension === '.markdown' + return isMarkdownDocumentPath(name) } function basenameFromRelativePath(relativePath: string): string { @@ -39,6 +47,8 @@ export function markdownDocumentFromFilePath( filePath: string, options: { outsideRootRelativePath?: 'basename' | 'relative' } = {} ): MarkdownDocument { + assertMarkdownDocumentPathWithinLimit(rootPath) + assertMarkdownDocumentPathWithinLimit(filePath) const basename = pathBasename(filePath) const extension = extname(basename) const relativePath = @@ -46,18 +56,23 @@ export function markdownDocumentFromFilePath( (options.outsideRootRelativePath === 'basename' ? basename : normalizeRelativePath(relative(rootPath, filePath))) - return { + const document = { filePath, relativePath, basename, name: extension ? basename.slice(0, -extension.length) : basename } + const budget = createMarkdownDocumentListingBudget() + retainMarkdownDocument(budget, document) + return document } export function markdownDocumentFromRelativePath( rootPath: string, relativePath: string ): MarkdownDocument | null { + assertMarkdownDocumentPathWithinLimit(rootPath) + assertMarkdownDocumentPathWithinLimit(relativePath) const normalizedRelativePath = normalizeRelativePath(relativePath) // Why: SSH providers should return root-relative paths; reject escape // segments before building a synthetic absolute path for renderer use. @@ -70,52 +85,42 @@ export function markdownDocumentFromRelativePath( } const extension = extname(basename) const normalizedRoot = rootPath.replace(/[\\/]+$/, '') - return { + const document = { filePath: `${normalizedRoot}/${normalizedRelativePath}`, relativePath: normalizedRelativePath, basename, name: extension ? basename.slice(0, -extension.length) : basename } + const budget = createMarkdownDocumentListingBudget() + retainMarkdownDocument(budget, document) + return document } export function markdownDocumentsFromRelativePaths( rootPath: string, - relativePaths: string[] + relativePaths: readonly string[], + limits: Partial = {} ): MarkdownDocument[] { - return relativePaths - .map((relativePath) => markdownDocumentFromRelativePath(rootPath, relativePath)) - .filter((document): document is MarkdownDocument => document !== null) - .sort((a, b) => a.relativePath.localeCompare(b.relativePath)) -} - -export async function listMarkdownDocuments(rootPath: string): Promise { + const budget = createMarkdownDocumentListingBudget(limits) const documents: MarkdownDocument[] = [] - - async function visitDirectory(dirPath: string): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isSymbolicLink()) { - continue - } - - const entryPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - if (entry.name === '.git' || entry.name === 'node_modules') { - continue - } - if (entry.name.startsWith('.') && entry.name !== '.github') { - continue - } - await visitDirectory(entryPath) - continue - } - - if (entry.isFile() && isMarkdownDocumentName(entry.name)) { - documents.push(markdownDocumentFromFilePath(rootPath, entryPath)) - } + for (const relativePath of relativePaths) { + const document = markdownDocumentFromRelativePath(rootPath, relativePath) + if (document) { + retainMarkdownDocument(budget, document) + documents.push(document) } } - - await visitDirectory(rootPath) return documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) } + +export async function listMarkdownDocuments( + rootPath: string, + limits: Partial = {} +): Promise { + const relativePaths = await discoverMarkdownRelativePaths(rootPath, { + limits, + shouldDescend: (_relativePath, name) => + name !== '.git' && name !== 'node_modules' && (!name.startsWith('.') || name === '.github') + }) + return markdownDocumentsFromRelativePaths(rootPath, relativePaths, limits) +} diff --git a/src/main/ipc/native-chat-ipc-admission.ts b/src/main/ipc/native-chat-ipc-admission.ts new file mode 100644 index 00000000000..6761459c500 --- /dev/null +++ b/src/main/ipc/native-chat-ipc-admission.ts @@ -0,0 +1,113 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +const DESKTOP_READ_WINDOW = 300 + +export const MAX_NATIVE_CHAT_DESKTOP_READ_LIMIT = 2_000 +export const MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER = 64 +export const MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE = 256 +export const MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER = 64 +export const MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PROCESS_WIDE = 256 +export const MAX_NATIVE_CHAT_READS_PER_SENDER = 8 +export const MAX_NATIVE_CHAT_READS_PROCESS_WIDE = 16 +export const MAX_NATIVE_CHAT_SUBSCRIPTION_ID_BYTES = 1_024 +export const MAX_NATIVE_CHAT_SESSION_ID_BYTES = 4 * 1_024 +export const MAX_NATIVE_CHAT_TRANSCRIPT_PATH_BYTES = 64 * 1_024 +export const MAX_NATIVE_CHAT_AGENT_TYPE_BYTES = 64 + +function boundedString( + value: unknown, + maxBytes: number, + options: { allowEmpty?: boolean } = {} +): value is string { + if (typeof value !== 'string' || (!options.allowEmpty && value.length === 0)) { + return false + } + return !measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit +} + +export function isValidNativeChatTranscriptRequestStrings(args: { + agent: unknown + sessionId: unknown + transcriptPath?: unknown +}): boolean { + return ( + boundedString(args.agent, MAX_NATIVE_CHAT_AGENT_TYPE_BYTES) && + boundedString(args.sessionId, MAX_NATIVE_CHAT_SESSION_ID_BYTES, { allowEmpty: true }) && + (args.transcriptPath === undefined || + boundedString(args.transcriptPath, MAX_NATIVE_CHAT_TRANSCRIPT_PATH_BYTES, { + allowEmpty: true + })) + ) +} + +export function isValidNativeChatSubscriptionId(value: unknown): value is string { + return boundedString(value, MAX_NATIVE_CHAT_SUBSCRIPTION_ID_BYTES) +} + +export function normalizeNativeChatDesktopReadLimit(limit: unknown): number { + if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) { + return DESKTOP_READ_WINDOW + } + return Math.min(MAX_NATIVE_CHAT_DESKTOP_READ_LIMIT, Math.floor(limit)) +} + +class NativeChatAttemptAdmission { + private readonly attempts = new Set() + private readonly attemptsBySender = new Map>() + + constructor( + private readonly maxPerSender: number, + private readonly maxProcessWide: number, + private readonly tokenLabel: string + ) {} + + claim(senderId: number): symbol | null { + const senderAttempts = this.attemptsBySender.get(senderId) + if ( + (senderAttempts?.size ?? 0) >= this.maxPerSender || + this.attempts.size >= this.maxProcessWide + ) { + return null + } + const token = Symbol(this.tokenLabel) + const nextSenderAttempts = senderAttempts ?? new Set() + nextSenderAttempts.add(token) + this.attemptsBySender.set(senderId, nextSenderAttempts) + this.attempts.add(token) + return token + } + + release(senderId: number, token: symbol): void { + this.attempts.delete(token) + const senderAttempts = this.attemptsBySender.get(senderId) + senderAttempts?.delete(token) + if (senderAttempts?.size === 0) { + this.attemptsBySender.delete(senderId) + } + } + + reset(): void { + this.attempts.clear() + this.attemptsBySender.clear() + } + + get size(): number { + return this.attempts.size + } +} + +export class NativeChatSetupAttemptAdmission extends NativeChatAttemptAdmission { + constructor() { + super( + MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER, + MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PROCESS_WIDE, + 'native-chat-setup' + ) + } +} + +export class NativeChatReadAdmission extends NativeChatAttemptAdmission { + constructor() { + super(MAX_NATIVE_CHAT_READS_PER_SENDER, MAX_NATIVE_CHAT_READS_PROCESS_WIDE, 'native-chat-read') + } +} diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 683e0271086..681f0c3310d 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -1,9 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatTurnLifecycle } from '../../shared/native-chat-types' -const { handlers, listeners, subscribeTranscript } = vi.hoisted(() => ({ +const { handlers, listeners, readTranscriptTail, subscribeTranscript } = vi.hoisted(() => ({ handlers: new Map unknown>(), listeners: new Map unknown>(), + readTranscriptTail: vi.fn(), subscribeTranscript: vi.fn() })) @@ -19,15 +20,28 @@ vi.mock('electron', () => ({ })) vi.mock('../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: readTranscriptTail, subscribeNativeChatTranscript: subscribeTranscript })) import { + _getNativeChatLiveSubscriptionCountForTest, _getNativeChatPendingSubscriptionCountForTest, + _getNativeChatReadCountForTest, _getNativeChatSenderCleanupCountForTest, + _getNativeChatSetupAttemptCountForTest, clearNativeChatSubscriptions, registerNativeChatHandlers } from './native-chat' +import { + MAX_NATIVE_CHAT_DESKTOP_READ_LIMIT, + MAX_NATIVE_CHAT_READS_PER_SENDER, + MAX_NATIVE_CHAT_SESSION_ID_BYTES, + MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER, + MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE, + MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER, + MAX_NATIVE_CHAT_SUBSCRIPTION_ID_BYTES +} from './native-chat-ipc-admission' type TestSubscription = { unsubscribe: ReturnType @@ -48,6 +62,7 @@ type SenderHarness = { id: number isDestroyed: () => boolean once: (event: string, callback: () => void) => void + removeListener: (event: string, callback: () => void) => void send: ReturnType } } @@ -56,6 +71,8 @@ beforeEach(() => { clearNativeChatSubscriptions() handlers.clear() listeners.clear() + readTranscriptTail.mockReset() + readTranscriptTail.mockResolvedValue({ messages: [], hasMore: false, beforeOffset: 0 }) subscribeTranscript.mockReset() registerNativeChatHandlers() }) @@ -82,7 +99,7 @@ function createSender(id: number): SenderHarness { return { destroy: () => { destroyed = true - for (const callback of destroyedCallbacks) { + for (const callback of destroyedCallbacks.slice()) { callback() } }, @@ -95,6 +112,15 @@ function createSender(id: number): SenderHarness { destroyedCallbacks.push(callback) } }, + removeListener: (event, callback) => { + if (event !== 'destroyed') { + return + } + const index = destroyedCallbacks.indexOf(callback) + if (index >= 0) { + destroyedCallbacks.splice(index, 1) + } + }, send: vi.fn() } } @@ -145,6 +171,166 @@ async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + it('clamps read and watch windows before transcript work starts', async () => { + const read = handlers.get('nativeChat:readSession') + if (!read) { + throw new Error('read handler not registered') + } + await read( + { sender: { id: 9 } }, + { agent: 'claude', sessionId: 'session', limit: Number.MAX_SAFE_INTEGER } + ) + expect(readTranscriptTail).toHaveBeenCalledWith( + expect.objectContaining({ limit: MAX_NATIVE_CHAT_DESKTOP_READ_LIMIT }) + ) + + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(10) + const listener = listeners.get('nativeChat:subscribe')! + listener( + { sender: renderer.sender }, + { + subscriptionId: 'bounded-window', + agent: 'claude', + sessionId: 'session', + limit: Number.MAX_SAFE_INTEGER + } + ) + expect(subscribeTranscript).toHaveBeenCalledWith( + expect.objectContaining({ initialLimit: MAX_NATIVE_CHAT_DESKTOP_READ_LIMIT }) + ) + pending.resolve() + await waitFor(() => _getNativeChatSetupAttemptCountForTest() === 0) + renderer.destroy() + }) + + it('rejects oversized retained identifiers before starting transcript work', async () => { + const renderer = createSender(11) + subscribe(renderer.sender, 'x'.repeat(MAX_NATIVE_CHAT_SUBSCRIPTION_ID_BYTES + 1)) + const listener = listeners.get('nativeChat:subscribe')! + listener( + { sender: renderer.sender }, + { + subscriptionId: 'valid', + agent: 'claude', + sessionId: 'x'.repeat(MAX_NATIVE_CHAT_SESSION_ID_BYTES + 1) + } + ) + + expect(subscribeTranscript).not.toHaveBeenCalled() + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + expect(_getNativeChatSetupAttemptCountForTest()).toBe(0) + }) + + it('bounds concurrent transcript pages per renderer and releases admission', async () => { + let releaseReads!: (value: { + messages: never[] + hasMore: boolean + beforeOffset: number + }) => void + const readGate = new Promise<{ + messages: never[] + hasMore: boolean + beforeOffset: number + }>((resolve) => { + releaseReads = resolve + }) + readTranscriptTail.mockReturnValue(readGate) + const read = handlers.get('nativeChat:readSession')! + const reads = Array.from({ length: MAX_NATIVE_CHAT_READS_PER_SENDER }, () => + read({ sender: { id: 20 } }, { agent: 'claude', sessionId: 'session' }) + ) + + await vi.waitFor(() => + expect(_getNativeChatReadCountForTest()).toBe(MAX_NATIVE_CHAT_READS_PER_SENDER) + ) + await expect( + read({ sender: { id: 20 } }, { agent: 'claude', sessionId: 'session' }) + ).resolves.toEqual({ error: 'Too many concurrent native chat transcript reads' }) + expect(readTranscriptTail).toHaveBeenCalledTimes(MAX_NATIVE_CHAT_READS_PER_SENDER) + + releaseReads({ messages: [], hasMore: false, beforeOffset: 0 }) + await Promise.all(reads) + expect(_getNativeChatReadCountForTest()).toBe(0) + }) + + it('caps unique logical subscriptions per renderer and recovers after unsubscribe', async () => { + subscribeTranscript.mockImplementation(async () => ({ + unsubscribe: vi.fn(), + watching: true + })) + const renderer = createSender(12) + for (let index = 0; index < MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER; index++) { + subscribe(renderer.sender, `sub-${index}`) + } + subscribe(renderer.sender, 'one-over') + await waitFor(() => _getNativeChatSetupAttemptCountForTest() === 0) + + expect(subscribeTranscript).toHaveBeenCalledTimes(MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER) + expect(_getNativeChatLiveSubscriptionCountForTest()).toBe( + MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER + ) + + unsubscribe(renderer.sender, 'sub-0') + subscribe(renderer.sender, 'after-release') + await waitFor(() => _getNativeChatSetupAttemptCountForTest() === 0) + expect(subscribeTranscript).toHaveBeenCalledTimes(MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER + 1) + renderer.destroy() + }) + + it('caps logical subscriptions across renderers process-wide', async () => { + subscribeTranscript.mockImplementation(async () => ({ + unsubscribe: vi.fn(), + watching: true + })) + const rendererCount = Math.ceil( + MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE / MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER + ) + const renderers = Array.from({ length: rendererCount + 1 }, (_, index) => + createSender(100 + index) + ) + for (let index = 0; index < MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE; index++) { + const renderer = renderers[Math.floor(index / MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER)] + subscribe(renderer.sender, `global-${index}`) + } + subscribe(renderers.at(-1)!.sender, 'global-one-over') + await waitFor(() => _getNativeChatSetupAttemptCountForTest() === 0) + + expect(subscribeTranscript).toHaveBeenCalledTimes(MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE) + expect(_getNativeChatLiveSubscriptionCountForTest()).toBe( + MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE + ) + renderers.forEach((renderer) => renderer.destroy()) + }) + + it('caps unresolved same-id setup attempts without displacing the latest admitted setup', async () => { + const pending = Array.from( + { length: MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER }, + deferredSubscription + ) + for (const setup of pending) { + subscribeTranscript.mockReturnValueOnce(setup.promise) + } + const renderer = createSender(13) + for (let index = 0; index < pending.length; index++) { + subscribe(renderer.sender, 'same-id-storm') + } + subscribe(renderer.sender, 'same-id-storm') + + expect(subscribeTranscript).toHaveBeenCalledTimes(MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER) + expect(_getNativeChatSetupAttemptCountForTest()).toBe(MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER) + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) + + pending.forEach((setup) => setup.resolve()) + await waitFor(() => _getNativeChatSetupAttemptCountForTest() === 0) + expect(pending.slice(0, -1).every((setup) => setup.unsubscribe.mock.calls.length === 1)).toBe( + true + ) + expect(pending.at(-1)?.unsubscribe).not.toHaveBeenCalled() + renderer.destroy() + }) + it('closes a watcher that resolves after renderer unsubscribe', async () => { const pending = deferredSubscription() subscribeTranscript.mockReturnValueOnce(pending.promise) @@ -161,6 +347,8 @@ describe('nativeChat subscribe lifecycle', () => { await waitFor(() => pending.unsubscribe.mock.calls.length === 1) unsubscribe(renderer.sender, 'unmount') expect(pending.unsubscribe).toHaveBeenCalledOnce() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + expect(renderer.registeredCleanupCount()).toBe(0) renderer.destroy() expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) }) @@ -209,7 +397,8 @@ describe('nativeChat subscribe lifecycle', () => { subscribe(renderer.sender, 'retry') failed.reject(new Error('watch setup failed')) await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) - expect(_getNativeChatSenderCleanupCountForTest()).toBe(1) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + expect(renderer.registeredCleanupCount()).toBe(0) subscribe(renderer.sender, 'retry') expect(renderer.registeredCleanupCount()).toBe(1) @@ -217,6 +406,7 @@ describe('nativeChat subscribe lifecycle', () => { await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) unsubscribe(renderer.sender, 'retry') expect(retry.unsubscribe).toHaveBeenCalledOnce() + expect(renderer.registeredCleanupCount()).toBe(0) renderer.destroy() expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) }) diff --git a/src/main/ipc/native-chat-subscription-registry.ts b/src/main/ipc/native-chat-subscription-registry.ts new file mode 100644 index 00000000000..0826039fd17 --- /dev/null +++ b/src/main/ipc/native-chat-subscription-registry.ts @@ -0,0 +1,178 @@ +import type { WebContents } from 'electron' +import type { NativeChatTranscriptSubscription } from '../native-chat/transcript-watch' +import { + MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER, + MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE, + NativeChatSetupAttemptAdmission +} from './native-chat-ipc-admission' + +export class NativeChatSubscriptionRegistry { + private readonly live = new Map>() + private readonly pending = new Map>() + private readonly senderCleanups = new Map< + number, + { onDestroyed: () => void; sender: WebContents } + >() + private readonly setupAttempts = new NativeChatSetupAttemptAdmission() + + canAdmit(senderId: number, subscriptionId: string): boolean { + if ( + this.live.get(senderId)?.has(subscriptionId) || + this.pending.get(senderId)?.has(subscriptionId) + ) { + return true + } + return ( + this.logicalCount(senderId) < MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER && + this.logicalCount() < MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE + ) + } + + claimSetup(senderId: number): symbol | null { + return this.setupAttempts.claim(senderId) + } + + releaseSetup(senderId: number, token: symbol): void { + this.setupAttempts.release(senderId, token) + } + + beginPending(senderId: number, subscriptionId: string): symbol { + this.teardown(senderId, subscriptionId) + const token = Symbol(subscriptionId) + const bySubscription = this.pending.get(senderId) ?? new Map() + bySubscription.set(subscriptionId, token) + this.pending.set(senderId, bySubscription) + return token + } + + takePending(senderId: number, subscriptionId: string, token: symbol): boolean { + const bySubscription = this.pending.get(senderId) + if (bySubscription?.get(subscriptionId) !== token) { + return false + } + bySubscription.delete(subscriptionId) + if (bySubscription.size === 0) { + this.pending.delete(senderId) + } + return true + } + + publish( + senderId: number, + subscriptionId: string, + subscription: NativeChatTranscriptSubscription + ): void { + const bySubscription = + this.live.get(senderId) ?? new Map() + bySubscription.get(subscriptionId)?.unsubscribe() + bySubscription.set(subscriptionId, subscription) + this.live.set(senderId, bySubscription) + } + + registerSenderCleanup(sender: WebContents): void { + const existing = this.senderCleanups.get(sender.id) + if (existing?.sender === sender) { + return + } + if (existing) { + this.teardownSender(sender.id) + } + const onDestroyed = (): void => { + if (this.senderCleanups.get(sender.id)?.sender === sender) { + this.teardownSender(sender.id) + } + } + this.senderCleanups.set(sender.id, { onDestroyed, sender }) + sender.once('destroyed', onDestroyed) + } + + teardown(senderId: number, subscriptionId: string): void { + const pendingBySubscription = this.pending.get(senderId) + pendingBySubscription?.delete(subscriptionId) + if (pendingBySubscription?.size === 0) { + this.pending.delete(senderId) + } + const liveBySubscription = this.live.get(senderId) + const subscription = liveBySubscription?.get(subscriptionId) + subscription?.unsubscribe() + liveBySubscription?.delete(subscriptionId) + if (liveBySubscription?.size === 0) { + this.live.delete(senderId) + } + this.releaseSenderCleanupIfIdle(senderId) + } + + releaseSenderCleanupIfIdle(senderId: number): void { + if (this.live.has(senderId) || this.pending.has(senderId)) { + return + } + this.releaseSenderCleanup(senderId) + } + + reset(): void { + const senderIds = new Set([ + ...this.live.keys(), + ...this.pending.keys(), + ...this.senderCleanups.keys() + ]) + for (const senderId of senderIds) { + this.teardownSender(senderId) + } + this.pending.clear() + this.setupAttempts.reset() + } + + get cleanupCount(): number { + return this.senderCleanups.size + } + + get pendingCount(): number { + let count = 0 + for (const bySubscription of this.pending.values()) { + count += bySubscription.size + } + return count + } + + get logicalSubscriptionCount(): number { + return this.logicalCount() + } + + get setupAttemptCount(): number { + return this.setupAttempts.size + } + + private logicalCount(senderId?: number): number { + if (senderId !== undefined) { + return (this.live.get(senderId)?.size ?? 0) + (this.pending.get(senderId)?.size ?? 0) + } + let count = 0 + const senderIds = new Set([...this.live.keys(), ...this.pending.keys()]) + for (const id of senderIds) { + count += this.logicalCount(id) + } + return count + } + + private teardownSender(senderId: number): void { + this.releaseSenderCleanup(senderId) + this.pending.delete(senderId) + const bySubscription = this.live.get(senderId) + if (!bySubscription) { + return + } + for (const subscription of bySubscription.values()) { + subscription.unsubscribe() + } + this.live.delete(senderId) + } + + private releaseSenderCleanup(senderId: number): void { + const cleanup = this.senderCleanups.get(senderId) + if (!cleanup) { + return + } + this.senderCleanups.delete(senderId) + cleanup.sender.removeListener('destroyed', cleanup.onDestroyed) + } +} diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index 4d0e1765a44..324d8422ed6 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -65,7 +65,7 @@ async function invokeReadSession(args: { if (!handler) { throw new Error('handler not registered') } - return handler({}, args) + return handler({ sender: { id: 1 } }, args) } describe('nativeChat:readSession handler', () => { @@ -197,6 +197,11 @@ describe('nativeChat:readSession handler', () => { destroyedCb = cb } }, + removeListener: (event: string, cb: () => void) => { + if (event === 'destroyed' && destroyedCb === cb) { + destroyedCb = undefined + } + }, send: (channel: string, payload: unknown) => sent.push({ channel, payload }) } @@ -283,6 +288,11 @@ describe('nativeChat:readSession handler', () => { destroyedCb = cb } }, + removeListener: (event: string, cb: () => void) => { + if (event === 'destroyed' && destroyedCb === cb) { + destroyedCb = undefined + } + }, send: vi.fn() } diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 6521bef3964..65cc1bfac6f 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -1,4 +1,4 @@ -import { ipcMain, type IpcMainEvent, type WebContents } from 'electron' +import { ipcMain, type IpcMainEvent } from 'electron' import type { AgentType, NativeChatMessage, @@ -11,6 +11,17 @@ import { readNativeChatTranscriptTail, type NativeChatTranscriptSubscription } from '../native-chat/transcript-watch' +import { + NATIVE_CHAT_TRANSCRIPT_PAGE_RESERVATION_BYTES, + nativeChatTranscriptReadAdmission +} from '../native-chat/transcript-read-admission' +import { + isValidNativeChatSubscriptionId, + isValidNativeChatTranscriptRequestStrings, + NativeChatReadAdmission, + normalizeNativeChatDesktopReadLimit +} from './native-chat-ipc-admission' +import { NativeChatSubscriptionRegistry } from './native-chat-subscription-registry' // Re-export so existing test imports of `clearNativeChatTranscriptCache` from // this module keep working after the cache moved to transcript-read-cache.ts. @@ -27,20 +38,43 @@ export type NativeChatReadSessionArgs = { transcriptPath?: string } -// Why: render and parse only the recent window so long transcripts do not stall -// either the main process or the message list. Pagination raises this limit. -const DESKTOP_READ_WINDOW = 300 +const readAdmission = new NativeChatReadAdmission() -async function readSession(args: NativeChatReadSessionArgs): Promise { - const { agent, sessionId } = args - // Clamp to a positive window; default to the desktop window for the first page. - const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW - return readNativeChatTranscriptTail({ - agent, - sessionId, - transcriptPath: args.transcriptPath, - limit - }) +async function readSession( + senderId: number, + args: NativeChatReadSessionArgs +): Promise { + if (!args || !isValidNativeChatTranscriptRequestStrings(args)) { + return { error: 'Invalid native chat transcript request' } + } + const readToken = readAdmission.claim(senderId) + if (!readToken) { + return { error: 'Too many concurrent native chat transcript reads' } + } + const { agent, sessionId, transcriptPath } = args + const limit = normalizeNativeChatDesktopReadLimit(args.limit) + let releaseMemory: (() => void) | undefined + try { + releaseMemory = await nativeChatTranscriptReadAdmission.acquire( + NATIVE_CHAT_TRANSCRIPT_PAGE_RESERVATION_BYTES + ) + return await readNativeChatTranscriptTail({ + agent, + sessionId, + transcriptPath, + limit + }) + } catch (error) { + if (!releaseMemory) { + return { + error: error instanceof Error ? error.message : 'Native chat transcript reader is busy' + } + } + throw error + } finally { + releaseMemory?.() + readAdmission.release(senderId, readToken) + } } export type NativeChatSubscribeArgs = { @@ -77,91 +111,28 @@ export type NativeChatAppendedPayload = { } } -type LiveSubscription = { - subscription: NativeChatTranscriptSubscription -} - -// Why: live subscriptions are keyed by (webContents.id, subscriptionId) so the -// same renderer can watch several panes, and a destroyed window tears down all -// of its watchers — strict teardown to avoid fd leaks (plan U4 risk). -const liveSubscriptions = new Map>() -// Why: unsubscribe and renderer destruction must invalidate async watcher setup -// before it can publish a late subscription into the live map. -const pendingSubscriptions = new Map>() -const senderCleanupRegistered = new Set() - -function teardownSubscription(senderId: number, subscriptionId: string): void { - const pendingBySubId = pendingSubscriptions.get(senderId) - pendingBySubId?.delete(subscriptionId) - if (pendingBySubId?.size === 0) { - pendingSubscriptions.delete(senderId) - } - const bySubId = liveSubscriptions.get(senderId) - const live = bySubId?.get(subscriptionId) - if (!live || !bySubId) { - return - } - live.subscription.unsubscribe() - bySubId.delete(subscriptionId) - if (bySubId.size === 0) { - liveSubscriptions.delete(senderId) - } -} - -function teardownAllForSender(senderId: number): void { - // The destroyed event can arrive before async subscription setup stores a watcher. - senderCleanupRegistered.delete(senderId) - pendingSubscriptions.delete(senderId) - const bySubId = liveSubscriptions.get(senderId) - if (!bySubId) { - return - } - for (const live of bySubId.values()) { - live.subscription.unsubscribe() - } - liveSubscriptions.delete(senderId) -} - -function registerSenderCleanup(sender: WebContents): void { - if (senderCleanupRegistered.has(sender.id)) { - return - } - senderCleanupRegistered.add(sender.id) - // Strict teardown: a closed/reloaded window releases every watcher it owns. - sender.once('destroyed', () => teardownAllForSender(sender.id)) -} - -function beginPendingSubscription(senderId: number, subscriptionId: string): symbol { - teardownSubscription(senderId, subscriptionId) - const token = Symbol(subscriptionId) - const bySubId = pendingSubscriptions.get(senderId) ?? new Map() - bySubId.set(subscriptionId, token) - pendingSubscriptions.set(senderId, bySubId) - return token -} - -function takePendingSubscription(senderId: number, subscriptionId: string, token: symbol): boolean { - const bySubId = pendingSubscriptions.get(senderId) - if (bySubId?.get(subscriptionId) !== token) { - return false - } - bySubId.delete(subscriptionId) - if (bySubId.size === 0) { - pendingSubscriptions.delete(senderId) - } - return true -} +const subscriptionRegistry = new NativeChatSubscriptionRegistry() async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise { const sender = event.sender - if (sender.isDestroyed()) { + if ( + sender.isDestroyed() || + !args || + !isValidNativeChatSubscriptionId(args.subscriptionId) || + !isValidNativeChatTranscriptRequestStrings(args) || + !subscriptionRegistry.canAdmit(sender.id, args.subscriptionId) + ) { return } const { subscriptionId, agent, sessionId, transcriptPath } = args - const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW + const setupAttempt = subscriptionRegistry.claimSetup(sender.id) + if (!setupAttempt) { + return + } + const limit = normalizeNativeChatDesktopReadLimit(args.limit) // Replace any prior subscription under the same id (session change/resubscribe). - const pendingToken = beginPendingSubscription(sender.id, subscriptionId) - registerSenderCleanup(sender) + const pendingToken = subscriptionRegistry.beginPending(sender.id, subscriptionId) + subscriptionRegistry.registerSenderCleanup(sender) let subscription: NativeChatTranscriptSubscription try { @@ -218,25 +189,21 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } }) } catch { - takePendingSubscription(sender.id, subscriptionId, pendingToken) + subscriptionRegistry.takePending(sender.id, subscriptionId, pendingToken) + subscriptionRegistry.releaseSenderCleanupIfIdle(sender.id) return + } finally { + subscriptionRegistry.releaseSetup(sender.id, setupAttempt) } // Why: unmount, destruction, or a newer same-id subscribe can invalidate setup // while path resolution is pending; only the owning token may publish its watcher. - const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pendingToken) + const stillCurrent = subscriptionRegistry.takePending(sender.id, subscriptionId, pendingToken) if (sender.isDestroyed() || !stillCurrent) { subscription.unsubscribe() return } - const bySubId = liveSubscriptions.get(sender.id) ?? new Map() - // A concurrent subscribe with the same id beat us here; honor the latest. - const existing = bySubId.get(subscriptionId) - if (existing) { - existing.subscription.unsubscribe() - } - bySubId.set(subscriptionId, { subscription }) - liveSubscriptions.set(sender.id, bySubId) + subscriptionRegistry.publish(sender.id, subscriptionId, subscription) if (!subscription.watching && !sender.isDestroyed()) { const payload: NativeChatAppendedPayload = { subscriptionId, @@ -253,34 +220,40 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg /** Test-only: drop all live and pending transcript subscriptions between runs. */ export function clearNativeChatSubscriptions(): void { - const senderIds = new Set([...liveSubscriptions.keys(), ...pendingSubscriptions.keys()]) - for (const senderId of senderIds) { - teardownAllForSender(senderId) - } - pendingSubscriptions.clear() - senderCleanupRegistered.clear() + subscriptionRegistry.reset() + readAdmission.reset() } export function _getNativeChatSenderCleanupCountForTest(): number { - return senderCleanupRegistered.size + return subscriptionRegistry.cleanupCount } export function _getNativeChatPendingSubscriptionCountForTest(): number { - let count = 0 - for (const bySubId of pendingSubscriptions.values()) { - count += bySubId.size - } - return count + return subscriptionRegistry.pendingCount +} + +export function _getNativeChatLiveSubscriptionCountForTest(): number { + return subscriptionRegistry.logicalSubscriptionCount +} + +export function _getNativeChatSetupAttemptCountForTest(): number { + return subscriptionRegistry.setupAttemptCount +} + +export function _getNativeChatReadCountForTest(): number { + return readAdmission.size } export function registerNativeChatHandlers(): void { - ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) => - readSession(args) + ipcMain.handle('nativeChat:readSession', (event, args: NativeChatReadSessionArgs) => + readSession(event.sender.id, args) ) ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => { void handleSubscribe(event, args) }) ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => { - teardownSubscription(event.sender.id, args.subscriptionId) + if (args && isValidNativeChatSubscriptionId(args.subscriptionId)) { + subscriptionRegistry.teardown(event.sender.id, args.subscriptionId) + } }) } diff --git a/src/main/ipc/native-notification-retention.ts b/src/main/ipc/native-notification-retention.ts new file mode 100644 index 00000000000..13336035e25 --- /dev/null +++ b/src/main/ipc/native-notification-retention.ts @@ -0,0 +1,104 @@ +import type { Notification } from 'electron' + +const NOTIFICATION_RELEASE_FALLBACK_MS = 5 * 60 * 1000 +export const MAX_ACTIVE_NATIVE_NOTIFICATIONS = 128 + +type RetainedNotification = { + evict: () => void + release: () => void +} + +export type RetainedNativeNotificationIdEntry = { + notification: Notification + release: () => void +} + +const activeNotifications = new Map() +const activeNotificationsById = new Map() + +export function retainNativeNotification( + notification: Notification, + onRelease?: () => void, + onEvict?: () => void, + options: { fallbackMs?: number | null } = {} +): () => void { + while (activeNotifications.size >= MAX_ACTIVE_NATIVE_NOTIFICATIONS) { + const oldest = activeNotifications.values().next() + if (oldest.done) { + break + } + oldest.value.evict() + } + + let released = false + let releaseTimer: ReturnType | null = null + + function release(): void { + if (released) { + return + } + released = true + activeNotifications.delete(notification) + notification.removeListener('close', release) + if (releaseTimer) { + clearTimeout(releaseTimer) + releaseTimer = null + } + onRelease?.() + } + + function evict(): void { + try { + onEvict?.() + } finally { + try { + notification.close() + } finally { + release() + } + } + } + + activeNotifications.set(notification, { evict, release }) + notification.on('close', release) + const fallbackMs = + options.fallbackMs === undefined ? NOTIFICATION_RELEASE_FALLBACK_MS : options.fallbackMs + if (fallbackMs !== null) { + releaseTimer = setTimeout(release, fallbackMs) + releaseTimer.unref?.() + } + return release +} + +export function getRetainedNativeNotificationById( + id: string +): RetainedNativeNotificationIdEntry | undefined { + return activeNotificationsById.get(id) +} + +export function setRetainedNativeNotificationId( + id: string, + entry: RetainedNativeNotificationIdEntry +): void { + activeNotificationsById.set(id, entry) +} + +export function deleteRetainedNativeNotificationId( + id: string, + expected: RetainedNativeNotificationIdEntry +): void { + if (activeNotificationsById.get(id) === expected) { + activeNotificationsById.delete(id) + } +} + +export function getActiveNativeNotificationCountForTest(): number { + return activeNotifications.size +} + +export function clearActiveNativeNotificationsForTest(): void { + for (const entry of Array.from(activeNotifications.values())) { + entry.evict() + } + activeNotificationsById.clear() +} diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index d68668038bd..78f1773d9c2 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -89,4 +89,23 @@ describe('notebook IPC', () => { expect(processKillSpy).toHaveBeenCalledWith(-4321, 'SIGKILL') } }) + + it('preserves output delivered as 100,000 one-byte subprocess events', async () => { + const proc = createMockProcess() + spawnMock.mockReturnValue(proc) + registerNotebookHandlers({} as never) + + const resultPromise = handlers.get('notebook:runPythonCell')?.(null, { + filePath: '/repo/notebook.ipynb', + code: 'print("x")' + }) as Promise<{ stdout: string }> + await Promise.resolve() + + for (let index = 0; index < 100_000; index += 1) { + proc.stdout.emit('data', Buffer.from('x')) + } + proc.emit('close', 0) + + await expect(resultPromise).resolves.toMatchObject({ stdout: 'x'.repeat(100_000) }) + }) }) diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index 9255ab7383d..080a94e5ca2 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -4,6 +4,7 @@ import { dirname } from 'node:path' import { ipcMain } from 'electron' import type { Store } from '../persistence' import { resolveAuthorizedPath } from './filesystem-auth' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' export type NotebookRunResult = { stdout: string @@ -16,9 +17,9 @@ const PYTHON_RUN_TIMEOUT_MS = 60_000 const MAX_CAPTURE_BYTES = 2 * 1024 * 1024 type BoundedCapture = { - text: string - bytes: number + buffer: GrowingByteBuffer truncated: boolean + truncationMarker: boolean } function pythonCandidates(): { command: string; argsPrefix: string[] }[] { @@ -38,19 +39,23 @@ function appendBounded(capture: BoundedCapture, chunk: Buffer): void { if (capture.truncated) { return } - const remainingBytes = MAX_CAPTURE_BYTES - capture.bytes + const remainingBytes = MAX_CAPTURE_BYTES - capture.buffer.byteLength if (remainingBytes <= 0) { capture.truncated = true return } if (chunk.byteLength <= remainingBytes) { - capture.text += chunk.toString('utf8') - capture.bytes += chunk.byteLength + capture.buffer.append(chunk) return } - capture.text += `${chunk.subarray(0, remainingBytes).toString('utf8')}\n[output truncated]\n` - capture.bytes = MAX_CAPTURE_BYTES + capture.buffer.append(chunk.subarray(0, remainingBytes)) capture.truncated = true + capture.truncationMarker = true +} + +function boundedCaptureText(capture: BoundedCapture): string { + const text = capture.buffer.toString() + return capture.truncationMarker ? `${text}\n[output truncated]\n` : text } function terminateNotebookProcessTree( @@ -117,8 +122,16 @@ async function runPythonCandidate( cwd: string ): Promise { return new Promise((resolve) => { - const stdout: BoundedCapture = { text: '', bytes: 0, truncated: false } - const stderr: BoundedCapture = { text: '', bytes: 0, truncated: false } + const stdout: BoundedCapture = { + buffer: new GrowingByteBuffer(), + truncated: false, + truncationMarker: false + } + const stderr: BoundedCapture = { + buffer: new GrowingByteBuffer(), + truncated: false, + truncationMarker: false + } let settled = false let forceKillTimer: ReturnType | null = null let timeout: ReturnType | null = null @@ -162,8 +175,8 @@ async function runPythonCandidate( forceKillTimer = terminateNotebookProcessTree(child) finish( { - stdout: stdout.text, - stderr: stderr.text, + stdout: boundedCaptureText(stdout), + stderr: boundedCaptureText(stderr), exitCode: null, error: 'Python cell timed out.' }, @@ -178,12 +191,17 @@ async function runPythonCandidate( appendBounded(stderr, chunk) } const onError = (error: Error): void => { - finish({ stdout: stdout.text, stderr: stderr.text, exitCode: null, error: error.message }) + finish({ + stdout: boundedCaptureText(stdout), + stderr: boundedCaptureText(stderr), + exitCode: null, + error: error.message + }) } const onClose = (exitCode: number | null): void => { finish({ - stdout: stdout.text, - stderr: stderr.text, + stdout: boundedCaptureText(stdout), + stderr: boundedCaptureText(stderr), exitCode }) } diff --git a/src/main/ipc/notification-ipc-admission.test.ts b/src/main/ipc/notification-ipc-admission.test.ts new file mode 100644 index 00000000000..a4a70a6f377 --- /dev/null +++ b/src/main/ipc/notification-ipc-admission.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_NOTIFICATION_DISPATCH_INPUT_BYTES, + normalizeNotificationDispatchRequest +} from './notification-ipc-admission' + +describe('notification IPC admission', () => { + it('copies only whitelisted fields so unknown payloads are not retained', () => { + const request = normalizeNotificationDispatchRequest({ + source: 'agent-task-complete', + worktreeId: 'repo::worktree', + requireDisplayConfirmation: true, + unknownPayload: 'x'.repeat(MAX_NOTIFICATION_DISPATCH_INPUT_BYTES * 2) + }) + + expect(request).toEqual({ + source: 'agent-task-complete', + worktreeId: 'repo::worktree', + requireDisplayConfirmation: true + }) + expect(request).not.toHaveProperty('unknownPayload') + }) + + it('rejects non-boolean control fields', () => { + for (const field of [ + 'requireDisplayConfirmation', + 'hasMultipleActiveRepos', + 'isActiveWorktree', + 'agentInterrupted' + ]) { + expect( + normalizeNotificationDispatchRequest({ + source: 'agent-task-complete', + [field]: 'true' + }) + ).toBeNull() + } + }) + + it('enforces the aggregate UTF-8 budget across known string fields', () => { + expect( + normalizeNotificationDispatchRequest({ + source: 'agent-task-complete', + agentPrompt: '😀'.repeat(MAX_NOTIFICATION_DISPATCH_INPUT_BYTES / 8), + agentLastAssistantMessage: '😀'.repeat(MAX_NOTIFICATION_DISPATCH_INPUT_BYTES / 8) + }) + ).toBeNull() + }) +}) diff --git a/src/main/ipc/notification-ipc-admission.ts b/src/main/ipc/notification-ipc-admission.ts new file mode 100644 index 00000000000..fb2c9ddb3a4 --- /dev/null +++ b/src/main/ipc/notification-ipc-admission.ts @@ -0,0 +1,122 @@ +import type { NotificationDispatchRequest } from '../../shared/types' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export const MAX_NOTIFICATION_DISPATCH_INPUT_BYTES = 256 * 1024 +export const MAX_NOTIFICATION_ID_BYTES = 8 * 1024 +export const MAX_NOTIFICATION_WORKTREE_ID_BYTES = 16 * 1024 +export const MAX_NOTIFICATION_PANE_KEY_BYTES = 8 * 1024 +export const MAX_NOTIFICATION_DISMISS_IDS = 256 +export const MAX_NOTIFICATION_DISMISS_SCAN_ENTRIES = 1_024 +export const MAX_NOTIFICATION_DISMISS_TOTAL_ID_BYTES = 256 * 1024 + +const STRING_FIELDS = [ + 'notificationId', + 'worktreeId', + 'paneKey', + 'repoLabel', + 'worktreeLabel', + 'terminalTitle', + 'agentType', + 'agentState', + 'agentPrompt', + 'agentToolName', + 'agentToolInput', + 'agentLastAssistantMessage' +] as const + +const BOOLEAN_FIELDS = [ + 'requireDisplayConfirmation', + 'hasMultipleActiveRepos', + 'isActiveWorktree', + 'agentInterrupted' +] as const + +function stringLimit(field: (typeof STRING_FIELDS)[number]): number { + if (field === 'notificationId') { + return MAX_NOTIFICATION_ID_BYTES + } + if (field === 'worktreeId') { + return MAX_NOTIFICATION_WORKTREE_ID_BYTES + } + if (field === 'paneKey') { + return MAX_NOTIFICATION_PANE_KEY_BYTES + } + return MAX_NOTIFICATION_DISPATCH_INPUT_BYTES +} + +export function normalizeNotificationDispatchRequest( + value: unknown +): NotificationDispatchRequest | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const request = value as Record + if ( + request.source !== 'agent-task-complete' && + request.source !== 'terminal-bell' && + request.source !== 'test' + ) { + return null + } + + const normalized: Record = { source: request.source } + let retainedBytes = measureUtf8ByteLength(request.source).byteLength + for (const field of STRING_FIELDS) { + const fieldValue = request[field] + if (fieldValue === undefined || fieldValue === null) { + continue + } + if (typeof fieldValue !== 'string') { + return null + } + const remaining = Math.min( + stringLimit(field), + MAX_NOTIFICATION_DISPATCH_INPUT_BYTES - retainedBytes + ) + const measured = measureUtf8ByteLength(fieldValue, { stopAfterBytes: remaining }) + if (measured.exceededLimit) { + return null + } + retainedBytes += measured.byteLength + normalized[field] = fieldValue + } + for (const field of BOOLEAN_FIELDS) { + const fieldValue = request[field] + if (fieldValue === undefined) { + continue + } + if (typeof fieldValue !== 'boolean') { + return null + } + normalized[field] = fieldValue + } + return normalized as NotificationDispatchRequest +} + +export function boundedUniqueNotificationDismissIds(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + const ids: string[] = [] + const unique = new Set() + let retainedBytes = 0 + const scanCount = Math.min(value.length, MAX_NOTIFICATION_DISMISS_SCAN_ENTRIES) + for (let index = 0; index < scanCount && ids.length < MAX_NOTIFICATION_DISMISS_IDS; index++) { + const id = value[index] + if (typeof id !== 'string' || id.length === 0 || unique.has(id)) { + continue + } + const remaining = Math.min( + MAX_NOTIFICATION_ID_BYTES, + MAX_NOTIFICATION_DISMISS_TOTAL_ID_BYTES - retainedBytes + ) + const measured = measureUtf8ByteLength(id, { stopAfterBytes: remaining }) + if (measured.exceededLimit) { + continue + } + unique.add(id) + ids.push(id) + retainedBytes += measured.byteLength + } + return ids +} diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index d7930066ad7..feb4bc798e0 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -90,6 +90,11 @@ vi.mock('../tray/system-tray', () => ({ })) import { + clearActiveNativeNotificationsForTest, + getActiveNativeNotificationCountForTest, + MAX_ACTIVE_NATIVE_NOTIFICATIONS, + MAX_NOTIFICATION_DISMISS_IDS, + MAX_NOTIFICATION_ID_BYTES, registerNotificationHandlers, triggerStartupNotificationRegistration } from './notifications' @@ -105,6 +110,7 @@ describe('registerNotificationHandlers', () => { beforeEach(() => { vi.useFakeTimers() + clearActiveNativeNotificationsForTest() vi.setSystemTime(new Date('2026-03-28T16:00:00Z')) tempDir = mkdtempSync(join(tmpdir(), 'orca-notification-test-')) removeHandlerMock.mockReset() @@ -1075,6 +1081,124 @@ describe('registerNotificationHandlers', () => { expect(notificationShowMock).toHaveBeenCalledTimes(2) }) + it('evicts the oldest retained notification at the process cap and keeps the newest', async () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never) + const dispatch = getDispatchHandler() + for (let index = 0; index <= MAX_ACTIVE_NATIVE_NOTIFICATIONS; index++) { + await dispatch({}, { source: 'test', notificationId: `notification-${index}` }) + } + + expect(getActiveNativeNotificationCountForTest()).toBe(MAX_ACTIVE_NATIVE_NOTIFICATIONS) + expect(notificationCloseMock).toHaveBeenCalledTimes(1) + const dismiss = getDismissHandler() + expect(dismiss({}, ['notification-0'])).toEqual({ dismissed: 0 }) + expect(dismiss({}, [`notification-${MAX_ACTIVE_NATIVE_NOTIFICATIONS}`])).toEqual({ + dismissed: 1 + }) + }) + + it('settles display confirmation when retention evicts its notification', async () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never) + const dispatch = getDispatchHandler() + const confirmations = Array.from({ length: MAX_ACTIVE_NATIVE_NOTIFICATIONS + 1 }, (_, index) => + Promise.resolve( + dispatch( + {}, + { + source: 'test', + notificationId: `confirmation-${index}`, + requireDisplayConfirmation: true + } + ) + ) + ) + await vi.advanceTimersByTimeAsync(0) + + await expect(confirmations[0]).resolves.toEqual({ + delivered: false, + reason: 'not-displayed' + }) + expect(getActiveNativeNotificationCountForTest()).toBe(MAX_ACTIVE_NATIVE_NOTIFICATIONS) + expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', expect.any(Function)) + expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', expect.any(Function)) + + clearActiveNativeNotificationsForTest() + await Promise.all(confirmations) + }) + + it('rejects oversized dispatch identifiers before native or mobile delivery', async () => { + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const result = await getDispatchHandler()( + {}, + { + source: 'agent-task-complete', + notificationId: '😀'.repeat(Math.floor(MAX_NOTIFICATION_ID_BYTES / 4) + 1) + } + ) + + expect(result).toEqual({ delivered: false, reason: 'invalid-request' }) + expect(notificationCtorMock).not.toHaveBeenCalled() + expect(dispatchMobileNotification).not.toHaveBeenCalled() + expect(setTrayAttentionMock).not.toHaveBeenCalled() + }) + + it('bounds dismiss deduplication and mobile fanout', () => { + const dismissMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never, + { dismissMobileNotification } as never + ) + const ids = Array.from( + { length: MAX_NOTIFICATION_DISMISS_IDS + 1 }, + (_, index) => `id-${index}` + ) + + expect(getDismissHandler()({}, ids)).toEqual({ dismissed: 0 }) + expect(dismissMobileNotification).toHaveBeenCalledTimes(MAX_NOTIFICATION_DISMISS_IDS) + expect(dismissMobileNotification).not.toHaveBeenCalledWith(`id-${MAX_NOTIFICATION_DISMISS_IDS}`) + }) + it('silences the native notification when a custom sound is configured', async () => { registerNotificationHandlers({ getSettings: () => ({ @@ -1326,6 +1450,26 @@ describe('registerNotificationHandlers', () => { }) }) + it('rejects a sparse custom sound above the bounded read limit', async () => { + const soundPath = join(tempDir, 'oversized.ogg') + writeFileSync(soundPath, '') + truncateSync(soundPath, 10 * 1024 * 1024 + 1) + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: soundPath + } + }) + } as never) + + const handler = getLoadSoundHandler() + await expect(handler({})).resolves.toEqual({ ok: false, reason: 'too-large' }) + }) + it('rejects unsupported custom sound file types', async () => { const soundPath = join(tempDir, 'sound.txt') writeFileSync(soundPath, 'not audio') @@ -1432,6 +1576,7 @@ describe('notifications:probeDelivery', () => { beforeEach(() => { vi.useFakeTimers() + clearActiveNativeNotificationsForTest() handleMock.mockReset() removeHandlerMock.mockReset() notificationCtorMock.mockClear() @@ -1594,6 +1739,7 @@ describe('triggerStartupNotificationRegistration', () => { beforeEach(() => { vi.useFakeTimers() + clearActiveNativeNotificationsForTest() vi.clearAllTimers() notificationCtorMock.mockClear() notificationShowMock.mockClear() diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 95e9e3e3a47..447cb03941e 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: notification IPC keeps permission, dispatch, custom sound asset, and sound-loading handlers colocated so renderer/main contracts stay auditable. */ import { app, BrowserWindow, Notification, ipcMain, shell } from 'electron' -import { readFile, stat } from 'node:fs/promises' import { extname, isAbsolute, normalize } from 'node:path' import beepSoundPath from '../../../resources/notification-sounds/beep.mp3?asset' import blipSoundPath from '../../../resources/notification-sounds/blip.mp3?asset' @@ -28,11 +27,40 @@ import { readNotificationAuthorizationStatus } from './notification-authorizatio import { parsePaneKey } from '../../shared/stable-pane-id' import { setTrayAttention } from '../tray/system-tray' import { isMainWindowVisible } from '../window/main-window-visibility' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' +import { + boundedUniqueNotificationDismissIds, + normalizeNotificationDispatchRequest +} from './notification-ipc-admission' +import { + deleteRetainedNativeNotificationId, + getRetainedNativeNotificationById, + retainNativeNotification, + setRetainedNativeNotificationId, + type RetainedNativeNotificationIdEntry +} from './native-notification-retention' + +export { + MAX_NOTIFICATION_DISMISS_IDS, + MAX_NOTIFICATION_DISMISS_SCAN_ENTRIES, + MAX_NOTIFICATION_DISMISS_TOTAL_ID_BYTES, + MAX_NOTIFICATION_DISPATCH_INPUT_BYTES, + MAX_NOTIFICATION_ID_BYTES, + MAX_NOTIFICATION_PANE_KEY_BYTES, + MAX_NOTIFICATION_WORKTREE_ID_BYTES +} from './notification-ipc-admission' +export { + clearActiveNativeNotificationsForTest, + getActiveNativeNotificationCountForTest, + MAX_ACTIVE_NATIVE_NOTIFICATIONS +} from './native-notification-retention' const NOTIFICATION_COOLDOWN_MS = 5000 const MAX_RECENT_NOTIFICATION_KEYS = 50 const NOTIFICATION_DISPLAY_CONFIRMATION_TIMEOUT_MS = 2500 -const NOTIFICATION_RELEASE_FALLBACK_MS = 5 * 60 * 1000 const MAX_NOTIFICATION_SOUND_BYTES = 10 * 1024 * 1024 const MACOS_PACKAGED_BUNDLE_ID = 'com.stablyai.orca' const MACOS_NOTIFICATION_SETTINGS_URL = @@ -58,44 +86,6 @@ const BUILT_IN_NOTIFICATION_SOUNDS: ReadonlyMap = new Map([ ]) type NotificationSoundId = NotificationSettings['customSoundId'] -// Why: keep a strong reference so GC can't collect notifications (and their click handlers) before the user interacts with them. -const activeNotifications = new Set() -const activeNotificationsById = new Map< - string, - { notification: Notification; release: () => void } ->() - -function retainNotificationUntilRelease( - notification: Notification, - onRelease?: () => void -): () => void { - activeNotifications.add(notification) - let released = false - let releaseTimer: ReturnType | null = null - - function release(): void { - if (released) { - return - } - released = true - activeNotifications.delete(notification) - notification.removeListener('close', release) - if (releaseTimer) { - clearTimeout(releaseTimer) - releaseTimer = null - } - onRelease?.() - } - - notification.on('close', release) - releaseTimer = setTimeout(release, NOTIFICATION_RELEASE_FALLBACK_MS) - if (typeof releaseTimer.unref === 'function') { - releaseTimer.unref() - } - - return release -} - const NOTIFICATION_PROBE_RESULT_TIMEOUT_MS = 3000 const NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS = 4000 @@ -123,20 +113,19 @@ function probeNotificationDelivery(): Promise { body: 'Orca will alert you when agents finish or terminals need attention.', silent: true }) - activeNotifications.add(probe) deliveryProbeInFlight = new Promise((resolve) => { let settled = false let timeoutTimer: ReturnType | null = null + let closeTimer: ReturnType | null = null + let releaseRetention = (): void => {} function releaseProbe(): void { - activeNotifications.delete(probe) - probe.removeListener('show', onShow) - probe.removeListener('failed', onFailed) + releaseRetention() probe.close() } - function settle(state: 'delivered' | 'blocked'): void { + function settle(state: 'delivered' | 'blocked', recordOutcome: boolean): void { if (settled) { return } @@ -145,14 +134,16 @@ function probeNotificationDelivery(): Promise { clearTimeout(timeoutTimer) timeoutTimer = null } - lastObservedDeliveryOutcome = state === 'delivered' ? 'delivered' : 'failed' + if (recordOutcome) { + lastObservedDeliveryOutcome = state === 'delivered' ? 'delivered' : 'failed' + } resolve({ state, authoritative: false }) } function onShow(): void { - settle('delivered') + settle('delivered', true) // Why: the probe banner doubles as the user-facing confirmation, so let it linger briefly instead of vanishing instantly. - const closeTimer = setTimeout(releaseProbe, NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS) + closeTimer = setTimeout(releaseProbe, NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS) if (typeof closeTimer.unref === 'function') { closeTimer.unref() } @@ -160,17 +151,34 @@ function probeNotificationDelivery(): Promise { function onFailed(_event: unknown, _error?: string): void { // Why: a rejected probe is expected (denied permission); don't log — it would spam the console on every poll. - settle('blocked') + settle('blocked', true) releaseProbe() } + releaseRetention = retainNativeNotification( + probe, + () => { + probe.removeListener('show', onShow) + probe.removeListener('failed', onFailed) + if (timeoutTimer) { + clearTimeout(timeoutTimer) + timeoutTimer = null + } + if (closeTimer) { + clearTimeout(closeTimer) + closeTimer = null + } + settle('blocked', false) + }, + () => settle('blocked', false), + { fallbackMs: null } + ) probe.once('show', onShow) probe.once('failed', onFailed) // Why: don't record 'failed' on timeout — a missing callback is ambiguous, only the 'failed' event is definitive. timeoutTimer = setTimeout(() => { if (!settled) { - settled = true - resolve({ state: 'blocked', authoritative: false }) + settle('blocked', false) releaseProbe() } }, NOTIFICATION_PROBE_RESULT_TIMEOUT_MS) @@ -228,8 +236,12 @@ function getSelectedNotificationSoundPath(settings: NotificationSettings): { return { path: normalizedPath } } -function waitForNotificationDisplay(notification: Notification): Promise { - return new Promise((resolve) => { +function waitForNotificationDisplay(notification: Notification): { + cancel: () => void + result: Promise +} { + let cancel = (): void => {} + const result = new Promise((resolve) => { let settled = false let timer: ReturnType | null = null @@ -250,6 +262,7 @@ function waitForNotificationDisplay(notification: Notification): Promise settle(false) function onShow(): void { settle(true) @@ -263,6 +276,7 @@ function waitForNotificationDisplay(notification: Notification): Promise settle(false), NOTIFICATION_DISPLAY_CONFIRMATION_TIMEOUT_MS) }) + return { cancel, result } } function logNativeNotificationFailure(context: string, error?: string): void { @@ -370,12 +384,10 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime ipcMain.removeHandler('notifications:dismiss') ipcMain.handle('notifications:dismiss', (_event, ids: string[]): NotificationDismissResult => { - const uniqueIds = Array.from( - new Set(ids.filter((id): id is string => typeof id === 'string' && id.length > 0)) - ) + const uniqueIds = boundedUniqueNotificationDismissIds(ids) let dismissed = 0 for (const id of uniqueIds) { - const entry = activeNotificationsById.get(id) + const entry = getRetainedNativeNotificationById(id) if (entry) { entry.notification.close() entry.release() @@ -391,8 +403,13 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime 'notifications:dispatch', ( _event, - args: NotificationDispatchRequest + request: unknown ): NotificationDispatchResult | Promise => { + const normalizedArgs = normalizeNotificationDispatchRequest(request) + if (!normalizedArgs) { + return { delivered: false, reason: 'invalid-request' } + } + const args: NotificationDispatchRequest = normalizedArgs // Why: light the tray attention dot before the cooldown/focus/enabled gates so they can't hold it back (clears on window show/restore; see index.ts). if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') { const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null @@ -465,7 +482,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime } const notification = new Notification(notificationOptions) if (args.notificationId) { - const previous = activeNotificationsById.get(args.notificationId) + const previous = getRetainedNativeNotificationById(args.notificationId) if (previous) { previous.notification.close() previous.release() @@ -475,9 +492,13 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime // Why: prevent GC from collecting the notification and its click handler while it's still visible. let clickHandler: (() => void) | null = null let failedHandler: ((_event: unknown, error?: string) => void) | null = null - const entryForId: { notification: Notification; release: () => void } | null = - args.notificationId ? { notification, release: () => {} } : null - const release = retainNotificationUntilRelease(notification, () => { + let cancelDisplayConfirmation: (() => void) | null = null + const entryForId: RetainedNativeNotificationIdEntry | null = args.notificationId + ? { notification, release: () => {} } + : null + const release = retainNativeNotification(notification, () => { + cancelDisplayConfirmation?.() + cancelDisplayConfirmation = null if (clickHandler) { notification.removeListener('click', clickHandler) clickHandler = null @@ -486,16 +507,13 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime notification.removeListener('failed', failedHandler) failedHandler = null } - if ( - args.notificationId && - activeNotificationsById.get(args.notificationId) === entryForId - ) { - activeNotificationsById.delete(args.notificationId) + if (args.notificationId && entryForId) { + deleteRetainedNativeNotificationId(args.notificationId, entryForId) } }) if (entryForId && args.notificationId) { entryForId.release = release - activeNotificationsById.set(args.notificationId, entryForId) + setRetainedNativeNotificationId(args.notificationId, entryForId) } failedHandler = (_event, error) => { @@ -546,10 +564,12 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime const displayConfirmation = args.requireDisplayConfirmation ? waitForNotificationDisplay(notification) : null + cancelDisplayConfirmation = displayConfirmation?.cancel ?? null notification.show() if (displayConfirmation) { - return displayConfirmation.then((displayed) => { + return displayConfirmation.result.then((displayed) => { + cancelDisplayConfirmation = null if (!displayed) { release() return { delivered: false, reason: 'not-displayed' } @@ -610,17 +630,18 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime } try { - const fileStat = await stat(normalizedPath) - if (!fileStat.isFile()) { + const { buffer, stats } = await readNodeFileWithinLimit( + normalizedPath, + MAX_NOTIFICATION_SOUND_BYTES + ) + if (!stats.isFile()) { return { ok: false, reason: 'invalid-path' } } - if (fileStat.size > MAX_NOTIFICATION_SOUND_BYTES) { + return { ok: true, data: new Uint8Array(buffer), mimeType, path: normalizedPath } + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { return { ok: false, reason: 'too-large' } } - - const data = await readFile(normalizedPath) - return { ok: true, data: new Uint8Array(data), mimeType, path: normalizedPath } - } catch { return { ok: false, reason: 'read-failed' } } }) @@ -647,12 +668,10 @@ export function triggerStartupNotificationRegistration(store: Store): void { body: 'Allow notifications so Orca can alert you when agents finish or terminals need attention.' }) - // Why: prevent GC from collecting the notification and its click handler while it's still visible. - activeNotifications.add(notification) - let handled = false let closeTimer: ReturnType | null = null let fallbackTimer: ReturnType | null = null + let releaseRetention = (): void => {} function clearStartupTimers(): void { if (closeTimer) { @@ -665,16 +684,22 @@ export function triggerStartupNotificationRegistration(store: Store): void { } } - function cleanup(): void { + function releaseState(): void { if (handled) { return } handled = true clearStartupTimers() - activeNotifications.delete(notification) notification.removeListener('click', onClick) notification.removeListener('show', onShow) notification.removeListener('failed', onFailed) + } + + function cleanup(): void { + if (handled) { + return + } + releaseRetention() notification.close() } @@ -702,6 +727,9 @@ export function triggerStartupNotificationRegistration(store: Store): void { notification.on('click', onClick) notification.on('show', onShow) notification.on('failed', onFailed) + releaseRetention = retainNativeNotification(notification, releaseState, undefined, { + fallbackMs: null + }) // Fallback in case macOS doesn't fire the 'show' event (e.g. user denies). fallbackTimer = setTimeout(cleanup, 10_000) diff --git a/src/main/ipc/pet-bundle.test.ts b/src/main/ipc/pet-bundle.test.ts index 096dd37e853..a9d25b11e77 100644 --- a/src/main/ipc/pet-bundle.test.ts +++ b/src/main/ipc/pet-bundle.test.ts @@ -4,9 +4,9 @@ import { CODEX_PET_ANIMATIONS, CODEX_PET_FRAME, CODEX_PET_SPRITESHEET_PATH, - applyCodexPetDefaults, - readWebpDimensionsFromBuffer + applyCodexPetDefaults } from './pet-bundle' +import { readRasterImageDimensions } from '../../shared/raster-image-dimensions' function u32(value: number): Buffer { const buffer = Buffer.alloc(4) @@ -129,15 +129,40 @@ describe('applyCodexPetDefaults', () => { }) }) -describe('readWebpDimensionsFromBuffer', () => { +describe('readRasterImageDimensions', () => { it('reads VP8X WebP canvas dimensions without decoding pixels', () => { - expect(readWebpDimensionsFromBuffer(webpVp8x(1536, 1872))).toEqual({ + expect(readRasterImageDimensions(webpVp8x(1536, 1872))).toEqual({ width: 1536, height: 1872 }) }) it('returns null for non-WebP data', () => { - expect(readWebpDimensionsFromBuffer(Buffer.from('not an image'))).toBeNull() + expect(readRasterImageDimensions(Buffer.from('not an image'))).toBeNull() + }) + + it('reads PNG and GIF dimensions without decoding pixels', () => { + const png = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png) + png.writeUInt32BE(13, 8) + png.write('IHDR', 12, 'ascii') + png.writeUInt32BE(8192, 16) + png.writeUInt32BE(512, 20) + const gif = Buffer.alloc(10) + gif.write('GIF89a', 0, 'ascii') + gif.writeUInt16LE(320, 6) + gif.writeUInt16LE(240, 8) + + expect(readRasterImageDimensions(png)).toEqual({ width: 8192, height: 512 }) + expect(readRasterImageDimensions(gif)).toEqual({ width: 320, height: 240 }) + }) + + it('reads JPEG dimensions without decoding pixels', () => { + const jpeg = Buffer.from([ + 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x10, 0x00, 0x20, 0x00, 0x03, 0x01, 0x11, 0x00, + 0x02, 0x11, 0x00, 0x03, 0x11, 0x00 + ]) + + expect(readRasterImageDimensions(jpeg)).toEqual({ width: 8192, height: 4096 }) }) }) diff --git a/src/main/ipc/pet-bundle.ts b/src/main/ipc/pet-bundle.ts index 1b701a7a105..6b39e393028 100644 --- a/src/main/ipc/pet-bundle.ts +++ b/src/main/ipc/pet-bundle.ts @@ -72,64 +72,3 @@ export function applyCodexPetDefaults( : codexAnimationsAtUniformFps(manifest.fps)) } } - -function readUInt24LE(buffer: Buffer, offset: number): number { - return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) -} - -export function readWebpDimensionsFromBuffer( - buffer: Buffer -): { width: number; height: number } | null { - if ( - buffer.byteLength < 20 || - buffer.toString('ascii', 0, 4) !== 'RIFF' || - buffer.toString('ascii', 8, 12) !== 'WEBP' - ) { - return null - } - - let offset = 12 - while (offset + 8 <= buffer.byteLength) { - const chunkType = buffer.toString('ascii', offset, offset + 4) - const chunkSize = buffer.readUInt32LE(offset + 4) - const dataOffset = offset + 8 - const dataEnd = dataOffset + chunkSize - if (dataEnd > buffer.byteLength) { - return null - } - - if (chunkType === 'VP8X' && chunkSize >= 10) { - return { - width: readUInt24LE(buffer, dataOffset + 4) + 1, - height: readUInt24LE(buffer, dataOffset + 7) + 1 - } - } - - if (chunkType === 'VP8L' && chunkSize >= 5 && buffer[dataOffset] === 0x2f) { - const b0 = buffer[dataOffset + 1] - const b1 = buffer[dataOffset + 2] - const b2 = buffer[dataOffset + 3] - const b3 = buffer[dataOffset + 4] - return { - width: 1 + (((b1 & 0x3f) << 8) | b0), - height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)) - } - } - - if ( - chunkType === 'VP8 ' && - chunkSize >= 10 && - buffer[dataOffset + 3] === 0x9d && - buffer[dataOffset + 4] === 0x01 && - buffer[dataOffset + 5] === 0x2a - ) { - const width = buffer.readUInt16LE(dataOffset + 6) & 0x3fff - const height = buffer.readUInt16LE(dataOffset + 8) & 0x3fff - return width > 0 && height > 0 ? { width, height } : null - } - - offset = dataEnd + (chunkSize % 2) - } - - return null -} diff --git a/src/main/ipc/pet.test.ts b/src/main/ipc/pet.test.ts index 5e857bb1244..d78b59f9ad5 100644 --- a/src/main/ipc/pet.test.ts +++ b/src/main/ipc/pet.test.ts @@ -8,14 +8,12 @@ const { browserWindowFromWebContentsMock, browserWindowGetFocusedWindowMock, handleMock, - nativeImageCreateFromBufferMock, showOpenDialogMock } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), browserWindowFromWebContentsMock: vi.fn(), browserWindowGetFocusedWindowMock: vi.fn(), handleMock: vi.fn(), - nativeImageCreateFromBufferMock: vi.fn(), showOpenDialogMock: vi.fn() })) @@ -32,9 +30,6 @@ vi.mock('electron', () => ({ }, ipcMain: { handle: handleMock - }, - nativeImage: { - createFromBuffer: nativeImageCreateFromBufferMock } })) @@ -54,7 +49,6 @@ describe('registerPetHandlers', () => { browserWindowFromWebContentsMock.mockReset() browserWindowGetFocusedWindowMock.mockReset() handleMock.mockReset() - nativeImageCreateFromBufferMock.mockReset() showOpenDialogMock.mockReset() appGetPathMock.mockReturnValue(userDataDir) @@ -63,10 +57,6 @@ describe('registerPetHandlers', () => { handleMock.mockImplementation((channel, handler) => { handlers.set(channel, handler) }) - nativeImageCreateFromBufferMock.mockReturnValue({ - isEmpty: () => true, - getSize: () => ({ width: 0, height: 0 }) - }) }) afterEach(async () => { @@ -82,9 +72,19 @@ describe('registerPetHandlers', () => { return handler } + function pngHeader(width: number, height: number): Buffer { + const png = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png) + png.writeUInt32BE(13, 8) + png.write('IHDR', 12, 'ascii') + png.writeUInt32BE(width, 16) + png.writeUInt32BE(height, 20) + return png + } + it('imports a pet bundle whose manifest uses Windows separators', async () => { const bundleDir = join(tempDir, 'windows-export.codex-pet') - const sheetBytes = Buffer.from('not decoded without frame metadata') + const sheetBytes = pngHeader(32, 24) await mkdir(join(bundleDir, 'assets'), { recursive: true }) await writeFile( join(bundleDir, 'pet.json'), @@ -147,6 +147,45 @@ describe('registerPetHandlers', () => { return bundleDir } + it('rejects unsafe raster dimensions from a metadata-free bundle before import', async () => { + const bundleDir = join(tempDir, 'dimension-bomb.codex-pet') + await mkdir(bundleDir, { recursive: true }) + await writeFile( + join(bundleDir, 'pet.json'), + JSON.stringify({ + spritesheetPath: 'sheet.png' + }) + ) + await writeFile(join(bundleDir, 'sheet.png'), pngHeader(8_193, 512)) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] }) + + await expect(getHandler('pet:importPetBundle')({ sender: {} })).rejects.toThrow( + 'exceed the safe limit' + ) + }) + + it('imports an under-limit legacy raster without changing its bytes', async () => { + const source = join(tempDir, 'safe-pet.png') + const sourceBytes = pngHeader(64, 48) + await writeFile(source, sourceBytes) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [source] }) + + const result = (await getHandler('pet:import')({ sender: {} })) as CustomPet + + expect(result).toMatchObject({ kind: 'image', mimeType: 'image/png' }) + await expect( + readFile(join(userDataDir, 'sidekicks', 'custom', result.fileName)) + ).resolves.toEqual(sourceBytes) + }) + + it('rejects a legacy raster dimension bomb before copying it', async () => { + const source = join(tempDir, 'dimension-bomb.png') + await writeFile(source, pngHeader(32_768, 32_768)) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [source] }) + + await expect(getHandler('pet:import')({ sender: {} })).rejects.toThrow('exceed the safe limit') + }) + it('imports a bundle whose animations declare per-frame durations', async () => { const bundleDir = await writeSpriteBundle({ idle: { row: 0, frames: 2, frameDurationsMs: [1680, 1920] } @@ -172,4 +211,27 @@ describe('registerPetHandlers', () => { 'declares 1 frame durations but 2 frames' ) }) + + it('reads a stored pet through the bounded file reader without changing its bytes', async () => { + const id = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const fileName = `${id}.png` + const storedDir = join(userDataDir, 'sidekicks', 'custom') + await mkdir(storedDir, { recursive: true }) + const storedBytes = pngHeader(4, 4) + await writeFile(join(storedDir, fileName), storedBytes) + + const result = (await getHandler('pet:read')({}, id, fileName, 'image')) as ArrayBuffer + + expect(Buffer.from(result)).toEqual(storedBytes) + }) + + it('rejects a replaced legacy pet dimension bomb before renderer delivery', async () => { + const id = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const fileName = `${id}.png` + const storedDir = join(userDataDir, 'sidekicks', 'custom') + await mkdir(storedDir, { recursive: true }) + await writeFile(join(storedDir, fileName), pngHeader(32_768, 32_768)) + + await expect(getHandler('pet:read')({}, id, fileName, 'image')).resolves.toBeNull() + }) }) diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index df48249f001..497e42b89ad 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -1,18 +1,19 @@ /* eslint-disable max-lines */ -import { app, BrowserWindow, dialog, ipcMain, nativeImage } from 'electron' -import { copyFile, mkdir, open, readFile, rename, rm, stat, lstat } from 'node:fs/promises' +import { app, BrowserWindow, dialog, ipcMain } from 'electron' +import { copyFile, mkdir, open, rename, rm, stat, lstat } from 'node:fs/promises' import { constants as fsConstants, createWriteStream } from 'node:fs' import { pipeline } from 'node:stream/promises' import { randomUUID } from 'node:crypto' import { basename, dirname, extname, isAbsolute, join, normalize, resolve, sep } from 'node:path' import { z } from 'zod' import type { CustomPet } from '../../shared/types' +import { applyCodexPetDefaults, type PetManifestLike, type ResolvedPetManifest } from './pet-bundle' import { - applyCodexPetDefaults, - readWebpDimensionsFromBuffer, - type PetManifestLike, - type ResolvedPetManifest -} from './pet-bundle' + MAX_CUSTOM_PET_FILE_BYTES, + isCustomPetSheetSizeSafe +} from '../../shared/custom-pet-media-limits' +import { readCustomPetFile } from './custom-pet-file-reader' +import { readRasterImageDimensions } from '../../shared/raster-image-dimensions' // Why: pets are image-only — render natively via (no 3D engine); main owns this format allowlist. const IMAGE_FORMATS: Record = { @@ -39,7 +40,6 @@ function getPetsDir(): string { return join(app.getPath('userData'), 'sidekicks', 'custom') } -const MAX_BYTES = 64 * 1024 * 1024 // 64 MB — generous but bounded so a user can't point at a multi-GB file and OOM the renderer when it builds a Blob URL. const MAX_MANIFEST_BYTES = 64 * 1024 // pet.json is tiny by spec; cap to defend against a malicious bundle stuffing megabytes into the manifest. function isSafeId(id: string): boolean { @@ -121,25 +121,24 @@ const PetFileRequestSchema = z.object({ kind: z.enum(['image', 'bundle']).optional() }) -async function readSheetDimensions( - buffer: Buffer -): Promise<{ width: number; height: number } | null> { - // Why: nativeImage can fail on some valid WebP that Chromium renders — read WebP dims from the header before native decode. - const webpDims = readWebpDimensionsFromBuffer(buffer) - if (webpDims) { - return webpDims - } - - // Why: nativeImage can't decode SVG (vector → no pixel grid) — pet bundles must use a raster sheet. - const image = nativeImage.createFromBuffer(buffer) - if (image.isEmpty()) { +function assertCustomPetRasterDimensions( + data: ArrayBuffer, + extension: string, + label: 'Pet image' | 'Spritesheet' +): { width: number; height: number } | null { + if (extension === '.svg') { return null } - const size = image.getSize() - if (size.width <= 0 || size.height <= 0) { - return null + const dimensions = readRasterImageDimensions(new Uint8Array(data)) + if (!dimensions) { + throw new Error(`Could not decode the ${label.toLowerCase()}.`) } - return { width: size.width, height: size.height } + if (!isCustomPetSheetSizeSafe(dimensions.width, dimensions.height)) { + throw new Error( + `${label} dimensions ${dimensions.width}×${dimensions.height} exceed the safe limit.` + ) + } + return dimensions } // Why: TOCTOU symlink-swap defense — O_NOFOLLOW makes open() fail on a symlink; Windows lacks it, so fall back to copyFile. @@ -201,11 +200,16 @@ export function registerPetHandlers(): void { if (!srcStat.isFile()) { throw new Error('Selected path is not a file') } - if (srcStat.size > MAX_BYTES) { + if (srcStat.size > MAX_CUSTOM_PET_FILE_BYTES) { throw new Error( - `File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_BYTES / (1024 * 1024)} MB.` + `File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_CUSTOM_PET_FILE_BYTES / (1024 * 1024)} MB.` ) } + if (classified.ext !== '.svg') { + const sourceBytes = await readCustomPetFile(src) + // Why: renderer delivery would otherwise let Chromium decode a compressed dimension bomb first. + assertCustomPetRasterDimensions(sourceBytes, classified.ext, 'Pet image') + } const dir = getPetsDir() await mkdir(dir, { recursive: true }) @@ -270,11 +274,9 @@ export function registerPetHandlers(): void { let manifest: ResolvedPetManifest try { - const raw = await readFile(manifestPath, 'utf8') - // Why: defend against TOCTOU — the file may have grown between the stat check and this read. - if (Buffer.byteLength(raw, 'utf8') > MAX_MANIFEST_BYTES) { - throw new Error('pet.json exceeded the manifest size limit.') - } + const raw = Buffer.from(await readCustomPetFile(manifestPath, MAX_MANIFEST_BYTES)).toString( + 'utf8' + ) manifest = applyCodexPetDefaults(PetManifestSchema.parse(JSON.parse(raw))) } catch (error) { throw new Error(`Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}`) @@ -318,24 +320,23 @@ export function registerPetHandlers(): void { if (!sheetStat.isFile()) { throw new Error('Spritesheet path is not a file.') } - if (sheetStat.size > MAX_BYTES) { + if (sheetStat.size > MAX_CUSTOM_PET_FILE_BYTES) { throw new Error( `Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).` ) } + const sheetBytes = await readCustomPetFile(sheetSrc) + // Why: metadata-free bundles also reach the renderer image decoder, so every raster needs the same pre-decode gate. + const sheetDimensions = assertCustomPetRasterDimensions( + sheetBytes, + sheetClass.ext, + 'Spritesheet' + ) + let sprite: NonNullable | undefined if (manifest.frame) { - // Why: only decode when a frame layout needs validating — nativeImage can fail on some WebP variants in headless contexts. - const sheetBuf = await readFile(sheetSrc) - // Why: defend against TOCTOU — file may have grown between stat and read. - if (sheetBuf.byteLength > MAX_BYTES) { - throw new Error('Spritesheet exceeded the size limit.') - } - const dims = await readSheetDimensions(sheetBuf) - if (!dims) { - throw new Error('Could not decode the spritesheet image.') - } + const dims = sheetDimensions! const { width: fw, height: fh } = manifest.frame if (dims.width % fw !== 0 || dims.height % fh !== 0) { throw new Error( @@ -432,8 +433,16 @@ export function registerPetHandlers(): void { return null } try { - const buf = await readFile(filePath) - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) + // Why: stored pet files can be replaced after import; the reader keeps + // one handle and never allocates beyond the same 64 MiB import limit. + const data = await readCustomPetFile(filePath) + const classified = classifyFile(filePath) + if (!classified) { + return null + } + // Why: this gates legacy image pets and metadata-free bundles before renderer decode. + assertCustomPetRasterDimensions(data, classified.ext, 'Pet image') + return data } catch (error) { console.warn('[pet-overlay] pet:read failed', error) return null diff --git a/src/main/ipc/pty-external-renderer-delivery.test.ts b/src/main/ipc/pty-external-renderer-delivery.test.ts new file mode 100644 index 00000000000..8ed151c07fa --- /dev/null +++ b/src/main/ipc/pty-external-renderer-delivery.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + onMock: vi.fn(), + removeHandlerMock: vi.fn(), + removeAllListenersMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + isPackaged: true, + getPath: () => '/tmp/orca-pty-external-delivery-test', + getVersion: () => '0.0.0-test' + }, + powerMonitor: { on: vi.fn() }, + nativeTheme: { shouldUseDarkColors: true }, + ipcMain: { + handle: handleMock, + on: onMock, + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + } +})) + +vi.mock('node-pty', () => ({ spawn: vi.fn() })) +vi.mock('../pwsh', () => ({ isPwshAvailable: vi.fn(() => false) })) + +import { LocalPtyProvider } from '../providers/local-pty-provider' +import { registerPtyHandlers, setLocalPtyProvider } from './pty' +import { + routeExternalPtyData, + routeExternalPtyExit, + routeExternalPtyReplay +} from './pty-renderer-delivery-router' + +describe('external PTY renderer delivery', () => { + const mainWindow = { + isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, + webContents: { + on: vi.fn(), + send: vi.fn(), + removeListener: vi.fn(), + isLoadingMainFrame: vi.fn(() => true) + } + } + const mainWindowEvent = { sender: mainWindow.webContents } + + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + onMock.mockImplementation((channel: string, listener: (...args: unknown[]) => void) => { + if (channel === 'pty:rendererDispatcherReady') { + listener(mainWindowEvent) + vi.advanceTimersByTime(0) + } + }) + }) + + afterEach(() => { + setLocalPtyProvider(new LocalPtyProvider()) + vi.useRealTimers() + }) + + function findIpcListener(channel: string): (...args: never[]) => void { + const call = onMock.mock.calls.findLast(([registered]) => registered === channel) + if (!call) { + throw new Error(`Missing ${channel} listener`) + } + return call[1] as (...args: never[]) => void + } + + function makeRuntime(sequence = 17) { + return { + setPtyController: vi.fn(), + onPtyData: vi.fn(() => sequence), + getPtyOutputSequence: vi.fn(() => sequence), + hasRawTerminalViewSubscriber: vi.fn(() => false) + } + } + + it('returns captured SSH credit only after the renderer acknowledges parsing', () => { + const runtime = makeRuntime() + const upstreamCredit = { charCount: 9, acknowledge: vi.fn() } + registerPtyHandlers(mainWindow as never, runtime as never) + mainWindow.webContents.send.mockClear() + + routeExternalPtyData({ + id: 'ssh:target@@pty-1', + data: '', + sequenceChars: 9, + transformed: true, + upstreamCredit + }) + vi.advanceTimersByTime(2) + + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'ssh:target@@pty-1', + '', + expect.any(Number), + 9, + true + ) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: 'ssh:target@@pty-1', + data: '', + seq: 17, + rawLength: 9, + transformed: true + }) + expect(upstreamCredit.acknowledge).not.toHaveBeenCalled() + + findIpcListener('pty:ackData')( + mainWindowEvent as never, + { + id: 'ssh:target@@pty-1', + processedChars: 9 + } as never + ) + + expect(upstreamCredit.acknowledge).toHaveBeenCalledOnce() + expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(9) + }) + + it('returns credit immediately when hidden output is intentionally dropped', () => { + const runtime = makeRuntime(42) + const upstreamCredit = { charCount: 13, acknowledge: vi.fn() } + registerPtyHandlers(mainWindow as never, runtime as never) + findIpcListener('pty:setHiddenRendererPty')( + null as never, + { + id: 'ssh:target@@pty-hidden', + hidden: true + } as never + ) + mainWindow.webContents.send.mockClear() + + routeExternalPtyData({ + id: 'ssh:target@@pty-hidden', + data: 'hidden output', + upstreamCredit + }) + + expect(runtime.onPtyData).toHaveBeenCalledOnce() + expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(13) + expect(mainWindow.webContents.send).toHaveBeenCalledOnce() + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'ssh:target@@pty-hidden', + reason: 'hidden-drop', + markerSeq: 42 + }) + }) + + it('settles queued live credit when a reconnect replay supersedes it', () => { + const runtime = makeRuntime() + const upstreamCredit = { charCount: 11, acknowledge: vi.fn() } + registerPtyHandlers(mainWindow as never, runtime as never) + mainWindow.webContents.send.mockClear() + + routeExternalPtyData({ + id: 'ssh:target@@pty-replay', + data: 'queued live', + upstreamCredit + }) + routeExternalPtyReplay({ id: 'ssh:target@@pty-replay', data: 'full snapshot' }) + vi.advanceTimersByTime(10) + + expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(11) + expect(mainWindow.webContents.send.mock.calls).toEqual([ + ['pty:replay', { id: 'ssh:target@@pty-replay', data: 'full snapshot' }] + ]) + }) + + it('writes off captured in-flight credit when the renderer lifecycle ends', () => { + const runtime = makeRuntime() + const upstreamCredit = { charCount: 7, acknowledge: vi.fn() } + registerPtyHandlers(mainWindow as never, runtime as never) + routeExternalPtyData({ + id: 'ssh:target@@pty-reload', + data: 'inflight', + upstreamCredit + }) + vi.advanceTimersByTime(2) + expect(upstreamCredit.acknowledge).not.toHaveBeenCalled() + + const lifecycleCall = mainWindow.webContents.on.mock.calls.findLast( + ([event]) => event === 'did-start-loading' + ) + const handleLifecycleReset = lifecycleCall?.[1] as (() => void) | undefined + expect(handleLifecycleReset).toBeTypeOf('function') + handleLifecycleReset?.() + + expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(7) + }) + + it('settles queued final-tail credit when exit flushes before the batch timer', () => { + const runtime = makeRuntime() + const upstreamCredit = { charCount: 10, acknowledge: vi.fn() } + registerPtyHandlers(mainWindow as never, runtime as never) + mainWindow.webContents.send.mockClear() + + routeExternalPtyData({ + id: 'ssh:target@@pty-final-tail', + data: 'final tail', + upstreamCredit + }) + routeExternalPtyExit({ id: 'ssh:target@@pty-final-tail', code: 0 }) + + expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(10) + expect(mainWindow.webContents.send.mock.calls).toEqual([ + [ + 'pty:data', + { + id: 'ssh:target@@pty-final-tail', + data: 'final tail', + seq: 17, + rawLength: 10 + } + ], + ['pty:exit', { id: 'ssh:target@@pty-final-tail', code: 0 }] + ]) + }) + + it('settles already-sent credit when exit arrives before renderer ACK', () => { + const runtime = makeRuntime() + const upstreamCredit = { charCount: 8, acknowledge: vi.fn() } + registerPtyHandlers(mainWindow as never, runtime as never) + + routeExternalPtyData({ + id: 'ssh:target@@pty-inflight-exit', + data: 'inflight', + upstreamCredit + }) + vi.advanceTimersByTime(2) + expect(upstreamCredit.acknowledge).not.toHaveBeenCalled() + + routeExternalPtyExit({ id: 'ssh:target@@pty-inflight-exit', code: 0 }) + + expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(8) + }) +}) diff --git a/src/main/ipc/pty-pending-data-drain-contract.ts b/src/main/ipc/pty-pending-data-drain-contract.ts new file mode 100644 index 00000000000..1768fb8c9d4 --- /dev/null +++ b/src/main/ipc/pty-pending-data-drain-contract.ts @@ -0,0 +1,22 @@ +export type PendingPtyData = { + data: string + startSeq?: number + rawLength?: number + transformed?: true + containsBackgroundOutput?: boolean + droppedOutput?: true +} + +export type PtyPendingDataDrainDisposition = 'active' | 'background' | 'blocked' + +type DrainPhase = 'active' | 'background' | 'done' + +export type PtyPendingDataDrainRound = { + readonly round: number + activeFrontier: number + backgroundFrontier: number + phase: DrainPhase + aborted: boolean +} + +export type PtyPendingDataDrainSelection = Readonly<{ id: string; pending: PendingPtyData }> diff --git a/src/main/ipc/pty-pending-data-drain-queue-differential.test.ts b/src/main/ipc/pty-pending-data-drain-queue-differential.test.ts new file mode 100644 index 00000000000..a36c0a24a5b --- /dev/null +++ b/src/main/ipc/pty-pending-data-drain-queue-differential.test.ts @@ -0,0 +1,412 @@ +import { describe, expect, it } from 'vitest' +import { PtyPendingDataDrainQueue, type PendingPtyData } from './pty-pending-data-drain-queue' + +const CHUNK_CHARS = 4 +const MAX_WRITES = 2 + +type Policy = { + active: Set + hidden: Set + interested: Set + credit: number +} + +type DrainEvent = { + kind: 'data' | 'drop' | 'marker' | 'sentinel' + id: string + data?: string + startSeq?: number + rawLength?: number + transformed?: true + containsBackgroundOutput?: boolean +} + +type DrainResult = { + events: DrainEvent[] + flow: { id: string; pendingChars: number }[] + timer: 'blocked' | 'continue' | 'idle' + writes: number +} + +function createPolicy(): Policy { + return { + active: new Set(), + hidden: new Set(), + interested: new Set(), + credit: 2 + } +} + +function isDroppable(policy: Policy, id: string): boolean { + return policy.hidden.has(id) && !policy.interested.has(id) +} + +function classify(policy: Policy, id: string) { + if (!isDroppable(policy, id) && policy.credit <= 0) { + return 'blocked' as const + } + return policy.active.has(id) ? ('active' as const) : ('background' as const) +} + +function clonePending(value: PendingPtyData): PendingPtyData { + return { ...value } +} + +function remainderFor(pending: PendingPtyData, chunk: string, remaining: string): PendingPtyData { + const next: PendingPtyData = { data: remaining } + if (typeof pending.startSeq === 'number') { + next.startSeq = pending.startSeq + chunk.length + } + if (pending.containsBackgroundOutput === true) { + next.containsBackgroundOutput = true + } + return next +} + +function dataEvent(id: string, pending: PendingPtyData, data: string): DrainEvent { + return { + kind: 'data', + id, + data, + ...(typeof pending.startSeq === 'number' ? { startSeq: pending.startSeq } : {}), + ...(typeof pending.rawLength === 'number' ? { rawLength: pending.rawLength } : {}), + ...(pending.transformed === true ? { transformed: true } : {}), + ...(pending.containsBackgroundOutput === true ? { containsBackgroundOutput: true } : {}) + } +} + +function recordExit(timeline: unknown[], id: string, pending: PendingPtyData | undefined): void { + if (pending) { + timeline.push( + pending.droppedOutput === true + ? { kind: 'sentinel', id, data: pending.data } + : dataEvent(id, pending, pending.data) + ) + timeline.push({ kind: 'flow', id, pendingChars: 0 }) + } + timeline.push({ kind: 'exit', id, hadPending: pending !== undefined }) +} + +function timerDecision(size: number, writes: number): DrainResult['timer'] { + if (size === 0) { + return 'idle' + } + return writes > 0 ? 'continue' : 'blocked' +} + +function recordDrop( + events: DrainEvent[], + markedDrops: Set, + id: string, + pending: PendingPtyData +): void { + events.push({ kind: 'drop', id, data: pending.data }) + if (!markedDrops.has(id)) { + markedDrops.add(id) + events.push({ kind: 'marker', id }) + } +} + +function drainLegacy( + pendingById: Map, + policy: Policy, + markedDrops: Set +): DrainResult { + const entries = [...pendingById.entries()] + const ordered = [ + ...entries.filter(([id]) => policy.active.has(id)), + ...entries.filter(([id]) => !policy.active.has(id)) + ] + const events: DrainEvent[] = [] + const flow: DrainResult['flow'] = [] + let writes = 0 + for (const [id, pending] of ordered) { + if (writes >= MAX_WRITES) { + break + } + if (isDroppable(policy, id)) { + pendingById.delete(id) + recordDrop(events, markedDrops, id, pending) + flow.push({ id, pendingChars: 0 }) + continue + } + if (policy.credit <= 0) { + continue + } + pendingById.delete(id) + if (pending.droppedOutput === true) { + events.push({ kind: 'sentinel', id, data: pending.data }) + } else { + const chunk = pending.transformed === true ? pending.data : pending.data.slice(0, CHUNK_CHARS) + const remaining = pending.transformed === true ? '' : pending.data.slice(CHUNK_CHARS) + if (remaining) { + pendingById.set(id, remainderFor(pending, chunk, remaining)) + } + events.push(dataEvent(id, pending, chunk)) + } + flow.push({ id, pendingChars: pendingById.get(id)?.data.length ?? 0 }) + policy.credit -= 1 + writes += 1 + } + return { events, flow, timer: timerDecision(pendingById.size, writes), writes } +} + +function drainQueue( + queue: PtyPendingDataDrainQueue, + policy: Policy, + markedDrops: Set +): DrainResult { + const events: DrainEvent[] = [] + const flow: DrainResult['flow'] = [] + let writes = 0 + const round = queue.beginRound() + try { + while (writes < MAX_WRITES) { + const selection = queue.takeNext(round) + if (!selection) { + break + } + const { id, pending } = selection + if (isDroppable(policy, id)) { + queue.remove(selection) + recordDrop(events, markedDrops, id, pending) + flow.push({ id, pendingChars: 0 }) + continue + } + if (policy.credit <= 0) { + queue.block(selection) + continue + } + if (pending.droppedOutput === true) { + queue.remove(selection) + events.push({ kind: 'sentinel', id, data: pending.data }) + } else { + const chunk = + pending.transformed === true ? pending.data : pending.data.slice(0, CHUNK_CHARS) + const remaining = pending.transformed === true ? '' : pending.data.slice(CHUNK_CHARS) + if (remaining) { + queue.replaceWithRemainder(selection, remainderFor(pending, chunk, remaining)) + } else { + queue.remove(selection) + } + events.push(dataEvent(id, pending, chunk)) + } + flow.push({ id, pendingChars: queue.get(id)?.data.length ?? 0 }) + policy.credit -= 1 + writes += 1 + } + } finally { + queue.endRound(round) + } + return { events, flow, timer: timerDecision(queue.size, writes), writes } +} + +function nextRandom(state: { value: number }): number { + let value = state.value + value ^= value << 13 + value ^= value >>> 17 + value ^= value << 5 + state.value = value >>> 0 + return state.value +} + +function setMembership(set: Set, id: string, present: boolean): boolean { + if (present) { + const changed = !set.has(id) + set.add(id) + return changed + } + return set.delete(id) +} + +function expectEquivalent( + legacy: Map, + queue: PtyPendingDataDrainQueue, + legacyPolicy: Policy, + queuePolicy: Policy, + legacyTimeline: unknown[], + queueTimeline: unknown[] +): void { + expect([...queue.keys()]).toEqual([...legacy.keys()]) + expect([...queue.values()]).toEqual([...legacy.values()]) + expect(queue.totalPendingChars).toBe( + [...legacy.values()].reduce((total, pending) => total + pending.data.length, 0) + ) + expect(queuePolicy.credit).toBe(legacyPolicy.credit) + expect(queueTimeline).toEqual(legacyTimeline) +} + +function runSeed(seed: number): void { + const legacy = new Map() + const legacyPolicy = createPolicy() + const queuePolicy = createPolicy() + const queue = new PtyPendingDataDrainQueue((id) => classify(queuePolicy, id)) + const legacyMarkedDrops = new Set() + const queueMarkedDrops = new Set() + const legacyTimeline: unknown[] = [] + const queueTimeline: unknown[] = [] + const random = { value: seed } + const ids = ['a', 'b', 'c', 'd', 'e', 'f'] + + for (let step = 0; step < 800; step++) { + const choice = nextRandom(random) % 13 + const id = ids[nextRandom(random) % ids.length]! + if (choice <= 2) { + const suffix = String.fromCharCode(97 + (nextRandom(random) % 26)).repeat( + 1 + (nextRandom(random) % 6) + ) + const current = legacy.get(id) + const next: PendingPtyData = current + ? { ...current, data: current.data + suffix } + : { + data: suffix, + startSeq: nextRandom(random) % 40, + ...(nextRandom(random) % 3 === 0 ? { containsBackgroundOutput: true } : {}) + } + legacy.set(id, clonePending(next)) + queue.set(id, clonePending(next)) + } else if (choice === 3) { + const active = nextRandom(random) % 2 === 0 + const changed = setMembership(legacyPolicy.active, id, active) + setMembership(queuePolicy.active, id, active) + if (changed) { + queue.invalidateAll() + } + } else if (choice === 4 || choice === 5) { + const setName = choice === 4 ? 'hidden' : 'interested' + const before = isDroppable(queuePolicy, id) + const present = nextRandom(random) % 2 === 0 + setMembership(legacyPolicy[setName], id, present) + setMembership(queuePolicy[setName], id, present) + if (!present && setName === 'hidden') { + legacyMarkedDrops.delete(id) + queueMarkedDrops.delete(id) + } + if (before !== isDroppable(queuePolicy, id)) { + queue.invalidateAll() + } + } else if (choice === 6) { + legacyPolicy.credit += 1 + queuePolicy.credit += 1 + queue.reactivateBlocked() + } else if (choice === 7) { + const sentinel = { data: `q${nextRandom(random) % 10}`, droppedOutput: true as const } + legacy.set(id, clonePending(sentinel)) + queue.set(id, clonePending(sentinel)) + } else if (choice === 8) { + const legacyPending = legacy.get(id) + legacy.delete(id) + const queuePending = queue.delete(id) + recordExit(legacyTimeline, id, legacyPending) + recordExit(queueTimeline, id, queuePending) + } else if (choice === 9) { + legacy.clear() + queue.clear() + legacyMarkedDrops.clear() + queueMarkedDrops.clear() + legacyTimeline.push({ kind: 'clear' }) + queueTimeline.push({ kind: 'clear' }) + } else { + const legacyRound = drainLegacy(legacy, legacyPolicy, legacyMarkedDrops) + const queueRound = drainQueue(queue, queuePolicy, queueMarkedDrops) + legacyTimeline.push(legacyRound) + queueTimeline.push(queueRound) + } + expectEquivalent(legacy, queue, legacyPolicy, queuePolicy, legacyTimeline, queueTimeline) + } +} + +describe('PtyPendingDataDrainQueue shared-domain differential', () => { + it.each([0x1a2b3c4d, 0x5eedc0de, 0x7f4a7c15])( + 'matches frozen Map behavior in the ordinary non-reentrant domain for seed %i', + (seed) => { + runSeed(seed) + } + ) + + it('defers every form of reentrant work until owner mutation is committed', () => { + const policy = createPolicy() + const queue = new PtyPendingDataDrainQueue((id) => classify(policy, id)) + queue.set('partial', { data: 'abcdefgh', startSeq: 10 }) + queue.set('unvisited', { data: 'old' }) + + const round = queue.beginRound() + const partial = queue.takeNext(round)! + queue.replaceWithRemainder(partial, { data: 'efgh', startSeq: 14 }) + queue.set('new', { data: 'new' }) + queue.set('partial', { data: 'efgh+same', startSeq: 14 }) + queue.set('unvisited', { data: 'old+append' }) + expect(queue.takeNext(round)).toBeNull() + queue.endRound(round) + + const next = queue.beginRound() + const ids: string[] = [] + for (;;) { + const selection = queue.takeNext(next) + if (!selection) { + break + } + ids.push(selection.id) + queue.remove(selection) + } + queue.endRound(next) + expect(ids).toEqual(['unvisited', 'partial', 'new']) + }) + + it('preserves transformed indivisibility and raw metadata', () => { + const policy = createPolicy() + policy.credit = 1 + const queue = new PtyPendingDataDrainQueue((id) => classify(policy, id)) + queue.set('transformed', { + data: 'abcdef', + startSeq: 7, + rawLength: 19, + transformed: true, + containsBackgroundOutput: true + }) + + expect(drainQueue(queue, policy, new Set())).toEqual({ + events: [ + { + kind: 'data', + id: 'transformed', + data: 'abcdef', + startSeq: 7, + rawLength: 19, + transformed: true, + containsBackgroundOutput: true + } + ], + flow: [{ id: 'transformed', pendingChars: 0 }], + timer: 'idle', + writes: 1 + }) + }) + + it('applies reentrant active and interest flips at the next frontier rebuild', () => { + const policy = createPolicy() + policy.credit = 0 + policy.hidden.add('drop-first') + policy.hidden.add('blocked') + policy.interested.add('blocked') + const queue = new PtyPendingDataDrainQueue((id) => classify(policy, id)) + queue.set('drop-first', { data: 'drop' }) + queue.set('blocked', { data: 'held' }) + + const round = queue.beginRound() + const first = queue.takeNext(round)! + expect(first.id).toBe('drop-first') + queue.remove(first) + policy.active.add('blocked') + policy.interested.delete('blocked') + queue.invalidateAll() + expect(queue.takeNext(round)).toBeNull() + queue.endRound(round) + + const next = queue.beginRound() + const reclassified = queue.takeNext(next)! + expect(reclassified.id).toBe('blocked') + queue.remove(reclassified) + queue.endRound(next) + }) +}) diff --git a/src/main/ipc/pty-pending-data-drain-queue.test.ts b/src/main/ipc/pty-pending-data-drain-queue.test.ts new file mode 100644 index 00000000000..c1773c64ff1 --- /dev/null +++ b/src/main/ipc/pty-pending-data-drain-queue.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, it } from 'vitest' +import { + PtyPendingDataDrainQueue, + type PendingPtyData, + type PtyPendingDataDrainSelection +} from './pty-pending-data-drain-queue' + +function pending(data: string): PendingPtyData { + return { data } +} + +function createQueueState() { + const active = new Set() + const blocked = new Set() + const queue = new PtyPendingDataDrainQueue((id) => + blocked.has(id) ? 'blocked' : active.has(id) ? 'active' : 'background' + ) + return { active, blocked, queue } +} + +function takeId( + queue: PtyPendingDataDrainQueue, + round: ReturnType +): string | null { + return queue.takeNext(round)?.id ?? null +} + +describe('PtyPendingDataDrainQueue', () => { + it('drains active IDs first while preserving Map order within each lane', () => { + const { active, queue } = createQueueState() + queue.set('background-1', pending('b1')) + queue.set('active-1', pending('a1')) + queue.set('background-2', pending('b2')) + queue.set('active-2', pending('a2')) + active.add('active-1') + active.add('active-2') + queue.invalidateAll() + + const round = queue.beginRound() + const order: string[] = [] + for (;;) { + const selection = queue.takeNext(round) + if (!selection) { + break + } + order.push(selection.id) + queue.remove(selection) + } + queue.endRound(round) + + expect(order).toEqual(['active-1', 'active-2', 'background-1', 'background-2']) + }) + + it('defers partial remainders beyond the current round frontier without replacing the node', () => { + const { queue } = createQueueState() + queue.set('pty-1', { data: 'firsttail', startSeq: 10 }) + const initialDebug = queue.getDebugSnapshot() + + const firstRound = queue.beginRound() + const selection = queue.takeNext(firstRound) + expect(selection).toMatchObject({ id: 'pty-1', pending: { data: 'firsttail', startSeq: 10 } }) + queue.replaceWithRemainder(selection!, { data: 'tail', startSeq: 15 }) + expect(queue.takeNext(firstRound)).toBeNull() + queue.endRound(firstRound) + + const secondRound = queue.beginRound() + const remainder = queue.takeNext(secondRound)! + expect(remainder).toBe(selection) + expect(remainder).toMatchObject({ + id: 'pty-1', + pending: { data: 'tail', startSeq: 15 } + }) + queue.remove(remainder) + queue.endRound(secondRound) + + expect(queue.getDebugSnapshot()).toMatchObject({ + pendingSize: 0, + createdNodeCount: initialDebug.createdNodeCount, + peakNodeCount: 1, + allocationIdsByPty: {} + }) + }) + + it('defers a reentrant append to an unvisited ID and preserves its Map position', () => { + const { queue } = createQueueState() + queue.set('a', pending('a')) + queue.set('b', pending('b')) + queue.set('c', pending('c')) + + const firstRound = queue.beginRound() + const first = queue.takeNext(firstRound)! + expect(first.id).toBe('a') + queue.remove(first) + queue.set('b', pending('b+reentrant')) + expect(queue.getDebugSnapshot().allocationIdsByPty.b).toBe(2) + + const currentRoundOrder: string[] = [] + for (;;) { + const selection = queue.takeNext(firstRound) + if (!selection) { + break + } + currentRoundOrder.push(selection.id) + queue.remove(selection) + } + queue.endRound(firstRound) + + expect(currentRoundOrder).toEqual(['c']) + expect([...queue.keys()]).toEqual(['b']) + const secondRound = queue.beginRound() + expect(queue.takeNext(secondRound)).toMatchObject({ + id: 'b', + pending: { data: 'b+reentrant' } + }) + queue.endRound(secondRound) + expect(queue.getDebugSnapshot().createdNodeCount).toBe(3) + }) + + it('rebuilds an earlier reentrant update before an untouched later ID in Map order', () => { + const { queue } = createQueueState() + queue.set('trigger', pending('trigger')) + queue.set('earlier', pending('old')) + queue.set('later', pending('later')) + + const firstRound = queue.beginRound() + queue.remove(queue.takeNext(firstRound)!) + queue.set('earlier', pending('new')) + queue.endRound(firstRound) + + const secondRound = queue.beginRound() + const order: string[] = [] + for (;;) { + const selection = queue.takeNext(secondRound) + if (!selection) { + break + } + order.push(selection.id) + queue.remove(selection) + } + queue.endRound(secondRound) + + expect(order).toEqual(['earlier', 'later']) + }) + + it('keeps aggregate pending chars exact across every owner transition', () => { + const { blocked, queue } = createQueueState() + queue.set('a', pending('abc')) + queue.set('b', pending('12345')) + expect(queue.totalPendingChars).toBe(8) + + queue.set('a', pending('abcdef')) + expect(queue.totalPendingChars).toBe(11) + blocked.add('a') + queue.invalidate('a') + const blockedRound = queue.beginRound() + const b = queue.takeNext(blockedRound)! + queue.replaceWithRemainder(b, pending('12')) + queue.endRound(blockedRound) + expect(queue.totalPendingChars).toBe(8) + + blocked.delete('a') + queue.reactivateBlocked() + const removalRound = queue.beginRound() + const first = queue.takeNext(removalRound)! + queue.remove(first) + queue.endRound(removalRound) + expect(queue.totalPendingChars).toBe(2) + + expect(queue.delete('b')).toEqual(pending('12')) + expect(queue.totalPendingChars).toBe(0) + queue.set('c', pending('tail')) + queue.clear() + expect(queue.getDebugSnapshot()).toMatchObject({ + pendingSize: 0, + totalPendingChars: 0 + }) + }) + + it('relinks only when the exact derived settings token changes', () => { + const settings = { + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + unrelated: 0 + } + const queue = new PtyPendingDataDrainQueue( + () => 'background', + () => settings.terminalMainSideEffectAuthority && settings.terminalHiddenDeliveryGate + ) + queue.set('a', pending('a')) + const firstRound = queue.beginRound() + queue.endRound(firstRound) + const baseline = queue.getDebugSnapshot().laneRebuildCount + + settings.unrelated += 1 + const unrelatedRound = queue.beginRound() + queue.endRound(unrelatedRound) + expect(queue.getDebugSnapshot().laneRebuildCount).toBe(baseline) + + settings.terminalHiddenDeliveryGate = false + const disabledRound = queue.beginRound() + queue.endRound(disabledRound) + expect(queue.getDebugSnapshot().laneRebuildCount).toBe(baseline + 1) + + settings.terminalMainSideEffectAuthority = false + settings.terminalHiddenDeliveryGate = true + const equivalentRound = queue.beginRound() + queue.endRound(equivalentRound) + expect(queue.getDebugSnapshot().laneRebuildCount).toBe(baseline + 1) + + settings.terminalMainSideEffectAuthority = true + const enabledRound = queue.beginRound() + queue.endRound(enabledRound) + expect(queue.getDebugSnapshot().laneRebuildCount).toBe(baseline + 2) + }) + + it('does not follow a detached successor after reentrant delete or clear', () => { + const deleted = createQueueState().queue + deleted.set('a', pending('a')) + deleted.set('b', pending('b')) + deleted.set('c', pending('c')) + const deleteRound = deleted.beginRound() + const first = deleted.takeNext(deleteRound)! + deleted.remove(first) + deleted.delete('b') + expect(takeId(deleted, deleteRound)).toBe('c') + deleted.endRound(deleteRound) + + const cleared = createQueueState().queue + cleared.set('a', pending('a')) + cleared.set('b', pending('b')) + const clearRound = cleared.beginRound() + cleared.remove(cleared.takeNext(clearRound)!) + cleared.clear() + expect(cleared.takeNext(clearRound)).toBeNull() + cleared.endRound(clearRound) + expect(cleared.getDebugSnapshot()).toMatchObject({ + pendingSize: 0, + activeRunnableSize: 0, + backgroundRunnableSize: 0, + blockedSize: 0, + activeHeadId: null, + activeTailId: null, + backgroundHeadId: null, + backgroundTailId: null, + blockedHeadId: null, + blockedTailId: null, + openRound: null, + activeFrontier: 0, + backgroundFrontier: 0, + allocationIdsByPty: {} + }) + }) + + it('reuses nodes through partial, update, and relink churn', () => { + const { queue } = createQueueState() + queue.set('a', pending('a')) + queue.set('b', pending('b')) + queue.set('c', pending('c')) + const initialAllocations = queue.getDebugSnapshot().allocationIdsByPty + + for (let index = 0; index < 60; index++) { + const round = queue.beginRound() + const selection = queue.takeNext(round)! + queue.replaceWithRemainder(selection, pending(`${selection.id}-${index}`)) + queue.endRound(round) + queue.set(selection.id, pending(`${selection.id}-${index}-updated`)) + queue.invalidateAll() + } + + expect(queue.getDebugSnapshot()).toMatchObject({ + pendingSize: 3, + createdNodeCount: 3, + peakNodeCount: 3, + allocationIdsByPty: initialAllocations + }) + + const finalRound = queue.beginRound() + queue.clear() + expect(queue.takeNext(finalRound)).toBeNull() + expect(queue.getDebugSnapshot()).toMatchObject({ + pendingSize: 0, + activeRunnableSize: 0, + backgroundRunnableSize: 0, + blockedSize: 0, + activeHeadId: null, + activeTailId: null, + backgroundHeadId: null, + backgroundTailId: null, + blockedHeadId: null, + blockedTailId: null, + openRound: null, + activeFrontier: 0, + backgroundFrontier: 0, + allocationIdsByPty: {} + }) + }) + + it('freezes active classification until the next round', () => { + const { active, queue } = createQueueState() + queue.set('a', pending('a')) + queue.set('b', pending('b')) + + const firstRound = queue.beginRound() + const first = queue.takeNext(firstRound)! + expect(first.id).toBe('a') + queue.remove(first) + active.add('b') + queue.invalidateAll() + expect(takeId(queue, firstRound)).toBe('b') + queue.endRound(firstRound) + + queue.set('c', pending('c')) + active.add('c') + queue.invalidateAll() + const secondRound = queue.beginRound() + expect(takeId(queue, secondRound)).toBe('b') + queue.endRound(secondRound) + }) + + it('reactivates blocked candidates in authoritative Map order', () => { + const { blocked, queue } = createQueueState() + blocked.add('a') + blocked.add('b') + queue.set('a', pending('a')) + queue.set('b', pending('b')) + + const blockedRound = queue.beginRound() + expect(queue.takeNext(blockedRound)).toBeNull() + queue.endRound(blockedRound) + expect(queue.getDebugSnapshot().blockedSize).toBe(2) + + blocked.clear() + expect(queue.reactivateBlocked()).toBe(true) + const creditedRound = queue.beginRound() + const first = queue.takeNext(creditedRound)! + queue.remove(first) + const second = queue.takeNext(creditedRound)! + queue.remove(second) + queue.endRound(creditedRound) + expect([first.id, second.id]).toEqual(['a', 'b']) + }) + + it('visits 100 one-chunk candidates exactly once across 50 bounded rounds', () => { + const { queue } = createQueueState() + const legacy = new Map() + for (let index = 0; index < 100; index++) { + const id = `pty-${index}` + const value = pending(String(index)) + queue.set(id, value) + legacy.set(id, value) + } + + let rounds = 0 + let timerDecisions = 1 + while (queue.size > 0) { + rounds += 1 + const round = queue.beginRound() + for (let writes = 0; writes < 2; writes++) { + const selection = queue.takeNext(round) + if (!selection) { + break + } + queue.remove(selection) + } + queue.endRound(round) + if (queue.size > 0) { + timerDecisions += 1 + } + } + + let legacySelectionVisits = 0 + let legacyTimerDecisions = 1 + while (legacy.size > 0) { + const snapshot = [...legacy.keys()] + legacySelectionVisits += snapshot.length + for (const id of snapshot.slice(0, 2)) { + legacy.delete(id) + } + if (legacy.size > 0) { + legacyTimerDecisions += 1 + } + } + + expect(rounds).toBe(50) + expect(timerDecisions).toBe(50) + expect(legacySelectionVisits).toBe(2_550) + expect(legacyTimerDecisions).toBe(50) + expect(queue.getDebugSnapshot()).toMatchObject({ + selectionVisitCount: 100, + createdNodeCount: 100, + peakNodeCount: 100, + pendingSize: 0, + activeRunnableSize: 0, + backgroundRunnableSize: 0, + blockedSize: 0, + activeHeadId: null, + activeTailId: null, + backgroundHeadId: null, + backgroundTailId: null, + blockedHeadId: null, + blockedTailId: null, + openRound: null, + activeFrontier: 0, + backgroundFrontier: 0, + allocationIdsByPty: {} + }) + }) + + it('requires each selection to commit before advancing', () => { + const { queue } = createQueueState() + queue.set('a', pending('a')) + queue.set('b', pending('b')) + const round = queue.beginRound() + const selection = queue.takeNext(round) + + expect(() => queue.takeNext(round)).toThrow( + 'PTY pending-data selection must be committed before advancing' + ) + expect(() => queue.set('a', pending('replacement'))).toThrow( + 'Selected PTY pending data must commit before update' + ) + queue.block(selection as PtyPendingDataDrainSelection) + expect(takeId(queue, round)).toBe('b') + queue.endRound(round) + }) +}) diff --git a/src/main/ipc/pty-pending-data-drain-queue.ts b/src/main/ipc/pty-pending-data-drain-queue.ts new file mode 100644 index 00000000000..378dc23de0d --- /dev/null +++ b/src/main/ipc/pty-pending-data-drain-queue.ts @@ -0,0 +1,335 @@ +import type * as Contract from './pty-pending-data-drain-contract' + +export type * from './pty-pending-data-drain-contract' + +type LinkedLaneName = Contract.PtyPendingDataDrainDisposition +type NodeLaneName = LinkedLaneName | 'none' | 'selected' + +type PendingNode = { + id: string + allocationId: number + pending: Contract.PendingPtyData + previous: PendingNode | null + next: PendingNode | null + lane: NodeLaneName + lanePosition: number + eligibleRound: number +} + +type Lane = { head: PendingNode | null; tail: PendingNode | null; size: number } + +function createLane(): Lane { + return { head: null, tail: null, size: 0 } +} + +export class PtyPendingDataDrainQueue { + private readonly nodes = new Map() + private readonly active = createLane() + private readonly background = createLane() + private readonly blocked = createLane() + private roundSerial = 0 + private lanePositionSerial = 0 + private openRound: Contract.PtyPendingDataDrainRound | null = null + private selectedNode: PendingNode | null = null + private relinkPending = false + private createdNodeCount = 0 + private peakNodeCount = 0 + private selectionVisitCount = 0 + private laneRebuildCount = 0 + private pendingChars = 0 + private lastPolicyToken: unknown + + constructor( + private readonly classify: (id: string) => Contract.PtyPendingDataDrainDisposition, + private readonly readPolicyToken?: () => unknown + ) { + this.lastPolicyToken = readPolicyToken?.() + } + + get size(): number { + return this.nodes.size + } + + get totalPendingChars(): number { + return this.pendingChars + } + + get(id: string): Contract.PendingPtyData | undefined { + return this.nodes.get(id)?.pending + } + + keys(): IterableIterator { + return this.nodes.keys() + } + + *values(): IterableIterator { + for (const node of this.nodes.values()) { + yield node.pending + } + } + + set(id: string, pending: Contract.PendingPtyData): void { + const existing = this.nodes.get(id) + if (!existing) { + const node: PendingNode = { + id, + allocationId: this.createdNodeCount + 1, + pending, + previous: null, + next: null, + lane: 'none', + lanePosition: 0, + eligibleRound: this.roundSerial + 1 + } + this.nodes.set(id, node) + this.pendingChars += pending.data.length + this.createdNodeCount += 1 + this.peakNodeCount = Math.max(this.peakNodeCount, this.nodes.size) + this.appendToLane(node, this.classify(id)) + return + } + + if (existing === this.selectedNode) { + throw new Error('Selected PTY pending data must commit before update') + } + this.pendingChars += pending.data.length - existing.pending.data.length + existing.pending = pending + if (this.openRound) { + // Why: renderer notification can synchronously append; defer the whole ID past this round's frontier. + this.unlink(existing) + existing.eligibleRound = this.openRound.round + 1 + this.appendToLane(existing, this.classify(id)) + this.relinkPending = true + } + } + + delete(id: string): Contract.PendingPtyData | undefined { + const node = this.nodes.get(id) + if (!node) { + return undefined + } + this.nodes.delete(id) + this.pendingChars -= node.pending.data.length + this.unlink(node) + if (this.selectedNode === node) { + this.selectedNode = null + } + return node.pending + } + + clear(): void { + if (this.openRound) { + this.openRound.aborted = true + } + this.nodes.clear() + this.pendingChars = 0 + this.resetLane(this.active) + this.resetLane(this.background) + this.resetLane(this.blocked) + this.openRound = null + this.selectedNode = null + this.relinkPending = false + this.lanePositionSerial = 0 + } + + invalidateAll(): boolean { + return this.requestRelink(this.nodes.size > 0) + } + + invalidate(id: string): boolean { + return this.requestRelink(this.nodes.has(id)) + } + + reactivateBlocked(): boolean { + return this.requestRelink(this.blocked.size > 0) + } + + beginRound(): Contract.PtyPendingDataDrainRound { + if (this.openRound) { + throw new Error('PTY pending-data drain round already open') + } + this.roundSerial += 1 + const policyToken = this.readPolicyToken?.() + if (!Object.is(policyToken, this.lastPolicyToken)) { + this.lastPolicyToken = policyToken + this.requestRelink(this.nodes.size > 0) + } + if (this.relinkPending) { + this.rebuildLanes() + } + const round: Contract.PtyPendingDataDrainRound = { + round: this.roundSerial, + activeFrontier: this.active.tail?.lanePosition ?? 0, + backgroundFrontier: this.background.tail?.lanePosition ?? 0, + phase: 'active', + aborted: false + } + this.openRound = round + return round + } + + takeNext(round: Contract.PtyPendingDataDrainRound): Contract.PtyPendingDataDrainSelection | null { + if (this.openRound !== round || round.aborted || round.phase === 'done') { + return null + } + if (this.selectedNode) { + throw new Error('PTY pending-data selection must be committed before advancing') + } + + while (round.phase !== 'done') { + const lane = round.phase === 'active' ? this.active : this.background + const frontier = round.phase === 'active' ? round.activeFrontier : round.backgroundFrontier + const node = lane.head + if (!node || node.lanePosition > frontier || node.eligibleRound > round.round) { + round.phase = round.phase === 'active' ? 'background' : 'done' + continue + } + this.unlink(node) + node.lane = 'selected' + this.selectedNode = node + this.selectionVisitCount += 1 + return node + } + return null + } + + block(selection: Contract.PtyPendingDataDrainSelection): void { + const node = this.requireSelectedNode(selection) + this.selectedNode = null + this.appendToLane(node, 'blocked') + } + + remove(selection: Contract.PtyPendingDataDrainSelection): void { + const node = this.requireSelectedNode(selection) + this.selectedNode = null + this.nodes.delete(node.id) + this.pendingChars -= node.pending.data.length + node.lane = 'none' + } + + replaceWithRemainder( + selection: Contract.PtyPendingDataDrainSelection, + pending: Contract.PendingPtyData + ): void { + const node = this.requireSelectedNode(selection) + this.selectedNode = null + this.nodes.delete(node.id) + this.pendingChars += pending.data.length - node.pending.data.length + node.pending = pending + node.eligibleRound = (this.openRound?.round ?? this.roundSerial) + 1 + this.nodes.set(node.id, node) + this.appendToLane(node, this.classify(node.id)) + } + + endRound(round: Contract.PtyPendingDataDrainRound): void { + if (this.openRound !== round) { + return + } + if (this.selectedNode) { + const selected = this.selectedNode + this.selectedNode = null + selected.eligibleRound = round.round + 1 + this.appendToLane(selected, this.classify(selected.id)) + this.relinkPending = true + } + this.openRound = null + } + + getDebugSnapshot() { + return { + pendingSize: this.nodes.size, + totalPendingChars: this.pendingChars, + activeRunnableSize: this.active.size, + backgroundRunnableSize: this.background.size, + blockedSize: this.blocked.size, + activeHeadId: this.active.head?.id ?? null, + activeTailId: this.active.tail?.id ?? null, + backgroundHeadId: this.background.head?.id ?? null, + backgroundTailId: this.background.tail?.id ?? null, + blockedHeadId: this.blocked.head?.id ?? null, + blockedTailId: this.blocked.tail?.id ?? null, + openRound: this.openRound?.round ?? null, + activeFrontier: this.openRound?.activeFrontier ?? 0, + backgroundFrontier: this.openRound?.backgroundFrontier ?? 0, + createdNodeCount: this.createdNodeCount, + peakNodeCount: this.peakNodeCount, + selectionVisitCount: this.selectionVisitCount, + laneRebuildCount: this.laneRebuildCount, + allocationIdsByPty: Object.fromEntries( + Array.from(this.nodes, ([id, node]) => [id, node.allocationId]) + ) + } + } + + private requireSelectedNode(selection: Contract.PtyPendingDataDrainSelection): PendingNode { + const node = selection as PendingNode + if (this.selectedNode !== node || this.nodes.get(node.id) !== node) { + throw new Error('Stale PTY pending-data drain selection') + } + return node + } + + private requestRelink(needed: boolean): boolean { + this.relinkPending ||= needed + return needed + } + + private rebuildLanes(): void { + this.laneRebuildCount += 1 + this.resetLane(this.active) + this.resetLane(this.background) + this.resetLane(this.blocked) + for (const node of this.nodes.values()) { + Object.assign(node, { previous: null, next: null, lane: 'none' as const }) + node.eligibleRound = this.roundSerial + this.appendToLane(node, this.classify(node.id)) + } + this.relinkPending = false + } + + private laneFor(name: LinkedLaneName): Lane { + return name === 'active' ? this.active : name === 'background' ? this.background : this.blocked + } + + private appendToLane(node: PendingNode, name: LinkedLaneName): void { + const lane = this.laneFor(name) + node.previous = lane.tail + node.next = null + node.lane = name + if (name !== 'blocked') { + this.lanePositionSerial += 1 + node.lanePosition = this.lanePositionSerial + } + if (lane.tail) { + lane.tail.next = node + } else { + lane.head = node + } + lane.tail = node + lane.size += 1 + } + + private unlink(node: PendingNode): void { + if (node.lane === 'none' || node.lane === 'selected') { + Object.assign(node, { previous: null, next: null, lane: 'none' as const }) + return + } + const lane = this.laneFor(node.lane) + if (node.previous) { + node.previous.next = node.next + } else { + lane.head = node.next + } + if (node.next) { + node.next.previous = node.previous + } else { + lane.tail = node.previous + } + lane.size -= 1 + Object.assign(node, { previous: null, next: null, lane: 'none' as const }) + } + + private resetLane(lane: Lane): void { + Object.assign(lane, { head: null, tail: null, size: 0 }) + } +} diff --git a/src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts b/src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts new file mode 100644 index 00000000000..c8db5b24c85 --- /dev/null +++ b/src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts @@ -0,0 +1,756 @@ +import { describe, expect, it } from 'vitest' +import { + PtyPendingDataDrainQueue, + type PendingPtyData, + type PtyPendingDataDrainSelection +} from './pty-pending-data-drain-queue' + +const CHUNK_CHARS = 4 +const MAX_WRITES = 2 +const PER_PTY_LIMIT = 8 +const TOTAL_LIMIT = 12 +const ACTIVE_PER_PTY_RESERVE = 4 +const ACTIVE_TOTAL_RESERVE = 4 + +type EngineKind = 'reference' | 'queue' +type Accounting = { sent: number; acked: number } +type DeliveryEvent = + | { + kind: 'data' + id: string + data: string + rawLength: number + transformed?: true + droppedOutput?: true + } + | { kind: 'exit'; id: string } + | { kind: 'ack'; id: string; processedChars: number; creditedChars: number } + | { kind: 'tick'; delayMs: number } + | { kind: 'drop'; id: string; data: string } + | { kind: 'clear' } + +type Candidate = { + id: string + pending: PendingPtyData + block(): void + remove(): void + replace(pending: PendingPtyData): void +} + +type EngineSnapshot = { + pending: [string, PendingPtyData][] + accounting: [string, Accounting][] + totalInFlight: number + flowPending: [string, number][] + scheduledDelay: number | null + timerArmCount: number + events: DeliveryEvent[] +} + +class DrainSchedulerEngine { + private readonly legacyPending = new Map() + private readonly queue: PtyPendingDataDrainQueue | null + private readonly active = new Set() + private readonly droppable = new Set() + private readonly accounting = new Map() + private readonly flowPending = new Map() + private readonly exiting = new Set() + private totalInFlight = 0 + private scheduledDelay: number | null = null + private timerArmCount = 0 + private clearGeneration = 0 + private draining = false + private creditReleasedWhileDraining = false + private notify: ((event: DeliveryEvent) => void) | null = null + readonly events: DeliveryEvent[] = [] + referenceSnapshotEntryVisits = 0 + + constructor(private readonly kind: EngineKind) { + this.queue = + kind === 'queue' + ? new PtyPendingDataDrainQueue((id) => { + if (this.droppable.has(id)) { + return this.active.has(id) ? 'active' : 'background' + } + if (!this.canSend(id)) { + return 'blocked' + } + return this.active.has(id) ? 'active' : 'background' + }) + : null + } + + enqueue(id: string, pending: PendingPtyData): void { + this.setPending(id, { ...pending }) + this.flowPending.set(id, pending.data.length) + this.schedule(2) + } + + setActive(id: string, active: boolean): void { + const changed = active ? !this.active.has(id) : this.active.has(id) + if (!changed) { + return + } + if (active) { + this.active.add(id) + } else { + this.active.delete(id) + } + if (this.getPending(id)) { + this.queue?.invalidateAll() + this.schedule(0) + } + } + + setDroppable(id: string, droppable: boolean): void { + const changed = droppable ? !this.droppable.has(id) : this.droppable.has(id) + if (!changed) { + return + } + if (droppable) { + this.droppable.add(id) + } else { + this.droppable.delete(id) + } + if (this.getPending(id)) { + this.queue?.invalidateAll() + this.schedule(0) + } + } + + acknowledge(id: string, processedChars: number): number { + const creditedChars = this.applyCumulativeAck(id, Math.max(0, processedChars)) + this.events.push({ kind: 'ack', id, processedChars, creditedChars }) + if (creditedChars > 0) { + this.queue?.reactivateBlocked() + } + if (this.pendingSize > 0) { + this.schedule(0) + } + return creditedChars + } + + tick(): void { + const delayMs = this.scheduledDelay + if (delayMs === null) { + throw new Error('No pending drain timer') + } + this.scheduledDelay = null + this.events.push({ kind: 'tick', delayMs }) + const generation = this.clearGeneration + let writes = 0 + this.draining = true + this.creditReleasedWhileDraining = false + + if (this.queue) { + const round = this.queue.beginRound() + try { + while (writes < MAX_WRITES) { + const selection = this.queue.takeNext(round) + if (!selection) { + break + } + writes += this.processCandidate(this.queueCandidate(selection)) + if (generation !== this.clearGeneration) { + break + } + } + } finally { + this.queue.endRound(round) + } + } else { + const entries = [...this.legacyPending.entries()] + const ordered = [ + ...entries.filter(([id]) => this.active.has(id)), + ...entries.filter(([id]) => !this.active.has(id)) + ] + this.referenceSnapshotEntryVisits += entries.length + for (const [id, pending] of ordered) { + if (writes >= MAX_WRITES || generation !== this.clearGeneration) { + break + } + if (!this.legacyPending.has(id)) { + continue + } + writes += this.processCandidate(this.legacyCandidate(id, pending)) + } + } + this.draining = false + const creditReleasedWhileDraining = this.creditReleasedWhileDraining + this.creditReleasedWhileDraining = false + + if (this.pendingSize > 0 && (writes > 0 || creditReleasedWhileDraining)) { + this.schedule(writes > 0 ? 1 : 0) + } + } + + exit(id: string): void { + if (this.exiting.has(id)) { + return + } + this.exiting.add(id) + try { + const hadReleasableCredit = this.inFlightFor(id) > 0 + const remaining = this.deletePending(id) + if (this.pendingSize === 0) { + this.scheduledDelay = null + } + this.flowPending.delete(id) + if (remaining) { + this.sendFinal(id, remaining) + } + const releasedChars = this.inFlightFor(id) + this.totalInFlight = Math.max(0, this.totalInFlight - releasedChars) + this.accounting.delete(id) + if (hadReleasableCredit && this.kind === 'queue') { + const reactivatedBlocked = this.queue?.reactivateBlocked() === true + if (this.draining) { + this.creditReleasedWhileDraining ||= reactivatedBlocked + } else if (this.pendingSize > 0) { + this.schedule(0) + } + } + this.events.push({ kind: 'exit', id }) + } finally { + this.exiting.delete(id) + } + } + + clear(): void { + this.clearGeneration += 1 + if (this.queue) { + this.queue.clear() + } else { + this.legacyPending.clear() + } + this.flowPending.clear() + this.accounting.clear() + this.totalInFlight = 0 + this.events.push({ kind: 'clear' }) + } + + setNotification(callback: ((event: DeliveryEvent) => void) | null): void { + this.notify = callback + } + + snapshot(): EngineSnapshot { + return { + pending: this.pendingEntries().map(([id, pending]) => [id, { ...pending }]), + accounting: Array.from(this.accounting, ([id, value]) => [id, { ...value }]), + totalInFlight: this.totalInFlight, + flowPending: [...this.flowPending.entries()], + scheduledDelay: this.scheduledDelay, + timerArmCount: this.timerArmCount, + events: this.events.map((event) => ({ ...event })) + } + } + + debugQueue(): ReturnType { + if (!this.queue) { + throw new Error('Legacy engine has no queue debug state') + } + return this.queue.getDebugSnapshot() + } + + private get pendingSize(): number { + return this.queue?.size ?? this.legacyPending.size + } + + private pendingEntries(): [string, PendingPtyData][] { + if (!this.queue) { + return [...this.legacyPending.entries()] + } + return [...this.queue.keys()].map((id) => [id, this.queue!.get(id)!]) + } + + private getPending(id: string): PendingPtyData | undefined { + return this.queue?.get(id) ?? this.legacyPending.get(id) + } + + private setPending(id: string, pending: PendingPtyData): void { + if (this.queue) { + this.queue.set(id, pending) + } else { + this.legacyPending.set(id, pending) + } + } + + private deletePending(id: string): PendingPtyData | undefined { + if (this.queue) { + return this.queue.delete(id) + } + const pending = this.legacyPending.get(id) + this.legacyPending.delete(id) + return pending + } + + private schedule(delayMs: number): void { + if (this.scheduledDelay !== null) { + return + } + this.scheduledDelay = delayMs + this.timerArmCount += 1 + } + + private inFlightFor(id: string): number { + const accounting = this.accounting.get(id) + return accounting ? accounting.sent - accounting.acked : 0 + } + + private canSend(id: string): boolean { + const active = this.active.has(id) + const perPtyLimit = PER_PTY_LIMIT + (active ? ACTIVE_PER_PTY_RESERVE : 0) + const totalLimit = TOTAL_LIMIT + (active ? ACTIVE_TOTAL_RESERVE : 0) + return this.inFlightFor(id) < perPtyLimit && this.totalInFlight < totalLimit + } + + private applyCumulativeAck(id: string, processedChars: number): number { + const accounting = this.accounting.get(id) + if (!accounting) { + return 0 + } + const nextAcked = Math.min(accounting.sent, Math.max(accounting.acked, processedChars)) + const credited = nextAcked - accounting.acked + accounting.acked = nextAcked + this.totalInFlight = Math.max(0, this.totalInFlight - credited) + return credited + } + + private recordSend(id: string, rawLength: number): void { + const accounting = this.accounting.get(id) + if (accounting) { + accounting.sent += rawLength + } else { + this.accounting.set(id, { sent: rawLength, acked: 0 }) + } + this.totalInFlight += rawLength + } + + private sendFinal(id: string, pending: PendingPtyData): void { + const rawLength = pending.rawLength ?? pending.data.length + this.recordSend(id, rawLength) + const event: DeliveryEvent = { + kind: 'data', + id, + data: pending.data, + rawLength, + ...(pending.transformed === true ? { transformed: true } : {}), + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) + } + this.events.push(event) + this.notify?.(event) + } + + private processCandidate(candidate: Candidate): number { + const { id, pending } = candidate + if (this.droppable.has(id)) { + candidate.remove() + this.flowPending.delete(id) + const event: DeliveryEvent = { kind: 'drop', id, data: pending.data } + this.events.push(event) + this.notify?.(event) + return 0 + } + if (!this.canSend(id)) { + candidate.block() + return 0 + } + if (pending.droppedOutput === true) { + candidate.remove() + this.flowPending.delete(id) + this.sendFinal(id, pending) + return 1 + } + + const indivisible = pending.transformed === true + const data = indivisible ? pending.data : pending.data.slice(0, CHUNK_CHARS) + const remaining = indivisible ? '' : pending.data.slice(CHUNK_CHARS) + if (remaining) { + const next: PendingPtyData = { data: remaining } + if (typeof pending.startSeq === 'number') { + next.startSeq = pending.startSeq + data.length + } + candidate.replace(next) + this.flowPending.set(id, remaining.length) + } else { + candidate.remove() + this.flowPending.delete(id) + } + + const rawLength = pending.rawLength ?? data.length + this.recordSend(id, rawLength) + const event: DeliveryEvent = { + kind: 'data', + id, + data, + rawLength, + ...(pending.transformed === true ? { transformed: true } : {}) + } + this.events.push(event) + this.notify?.(event) + return 1 + } + + private legacyCandidate(id: string, pending: PendingPtyData): Candidate { + return { + id, + pending, + block: () => {}, + remove: () => { + this.legacyPending.delete(id) + }, + replace: (next) => { + this.legacyPending.delete(id) + this.legacyPending.set(id, next) + } + } + } + + private queueCandidate(selection: PtyPendingDataDrainSelection): Candidate { + const queue = this.queue! + return { + id: selection.id, + pending: selection.pending, + block: () => queue.block(selection), + remove: () => queue.remove(selection), + replace: (pending) => queue.replaceWithRemainder(selection, pending) + } + } +} + +type EnginePair = { reference: DrainSchedulerEngine; queue: DrainSchedulerEngine } + +function createPair(): EnginePair { + return { + reference: new DrainSchedulerEngine('reference'), + queue: new DrainSchedulerEngine('queue') + } +} + +function applyBoth(pair: EnginePair, operation: (engine: DrainSchedulerEngine) => void): void { + operation(pair.reference) + operation(pair.queue) + expect(pair.queue.snapshot()).toEqual(pair.reference.snapshot()) +} + +function dataEvents(engine: DrainSchedulerEngine): Extract[] { + return engine.events.filter( + (event): event is Extract => event.kind === 'data' + ) +} + +describe('PTY pending-data hardened scheduler reference', () => { + it('models scheduled ticks and positive, zero, duplicate, stale, and clamped ACKs', () => { + const pair = createPair() + applyBoth(pair, (engine) => engine.enqueue('a', { data: 'abcdefghijklmnopqrst' })) + applyBoth(pair, (engine) => engine.tick()) + applyBoth(pair, (engine) => engine.tick()) + applyBoth(pair, (engine) => engine.tick()) + + const rebuildsBeforeZeroCredit = pair.queue.debugQueue().laneRebuildCount + applyBoth(pair, (engine) => engine.acknowledge('a', 0)) + const armsAfterZeroCredit = pair.queue.snapshot().timerArmCount + applyBoth(pair, (engine) => engine.acknowledge('a', -4)) + expect(pair.queue.snapshot().timerArmCount).toBe(armsAfterZeroCredit) + applyBoth(pair, (engine) => engine.tick()) + expect(pair.queue.debugQueue().laneRebuildCount).toBe(rebuildsBeforeZeroCredit) + + applyBoth(pair, (engine) => engine.acknowledge('a', 4)) + applyBoth(pair, (engine) => engine.tick()) + expect(pair.queue.debugQueue().laneRebuildCount).toBe(rebuildsBeforeZeroCredit + 1) + + const scheduledBeforeReplay = pair.queue.snapshot().scheduledDelay + const armsBeforeReplay = pair.queue.snapshot().timerArmCount + applyBoth(pair, (engine) => engine.acknowledge('a', 4)) + applyBoth(pair, (engine) => engine.acknowledge('a', 2)) + applyBoth(pair, (engine) => engine.acknowledge('a', 999)) + applyBoth(pair, (engine) => engine.acknowledge('a', 999)) + expect(pair.queue.snapshot()).toMatchObject({ + scheduledDelay: scheduledBeforeReplay, + timerArmCount: armsBeforeReplay + }) + + applyBoth(pair, (engine) => engine.tick()) + applyBoth(pair, (engine) => engine.tick()) + expect(pair.queue.snapshot()).toMatchObject({ + pending: [], + scheduledDelay: null, + totalInFlight: 8 + }) + }) + + it('wakes all globally blocked IDs when credit arrives for a different PTY', () => { + const pair = createPair() + applyBoth(pair, (engine) => + engine.enqueue('creditor', { data: '12345678', rawLength: 8, transformed: true }) + ) + applyBoth(pair, (engine) => engine.enqueue('blocked-b', { data: 'bbbbbbbb' })) + applyBoth(pair, (engine) => engine.enqueue('blocked-c', { data: 'cccc' })) + applyBoth(pair, (engine) => engine.tick()) + applyBoth(pair, (engine) => engine.tick()) + + expect(pair.queue.snapshot()).toMatchObject({ + totalInFlight: TOTAL_LIMIT, + scheduledDelay: null + }) + applyBoth(pair, (engine) => engine.acknowledge('creditor', 4)) + applyBoth(pair, (engine) => engine.tick()) + + expect(dataEvents(pair.queue).at(-1)).toMatchObject({ + id: 'blocked-c', + data: 'cccc' + }) + }) + + it('reclassifies blocked work for per-PTY and global active reserves', () => { + const perPty = createPair() + applyBoth(perPty, (engine) => engine.enqueue('active-later', { data: 'abcdefghijkl' })) + applyBoth(perPty, (engine) => engine.tick()) + applyBoth(perPty, (engine) => engine.tick()) + applyBoth(perPty, (engine) => engine.tick()) + applyBoth(perPty, (engine) => engine.setActive('active-later', true)) + applyBoth(perPty, (engine) => engine.tick()) + expect(perPty.queue.snapshot()).toMatchObject({ pending: [], totalInFlight: 12 }) + + const global = createPair() + applyBoth(global, (engine) => + engine.enqueue('bulk-a', { data: 'aaaaaaaa', rawLength: 8, transformed: true }) + ) + applyBoth(global, (engine) => + engine.enqueue('bulk-b', { data: 'bbbb', rawLength: 4, transformed: true }) + ) + applyBoth(global, (engine) => engine.tick()) + applyBoth(global, (engine) => engine.enqueue('active-at-cap', { data: 'cccc' })) + applyBoth(global, (engine) => engine.tick()) + applyBoth(global, (engine) => engine.setActive('active-at-cap', true)) + applyBoth(global, (engine) => engine.tick()) + expect(dataEvents(global.queue).at(-1)).toMatchObject({ + id: 'active-at-cap', + data: 'cccc' + }) + }) + + it('freezes lane order while a background candidate gains live active reserve', () => { + const pair = createPair() + applyBoth(pair, (engine) => + engine.enqueue('credit', { data: 'aaaaaaaa', rawLength: 8, transformed: true }) + ) + applyBoth(pair, (engine) => engine.tick()) + applyBoth(pair, (engine) => engine.setActive('trigger', true)) + applyBoth(pair, (engine) => engine.setActive('active-before', true)) + applyBoth(pair, (engine) => engine.setDroppable('trigger', true)) + applyBoth(pair, (engine) => engine.enqueue('trigger', { data: 'drop' })) + applyBoth(pair, (engine) => engine.enqueue('active-before', { data: 'aaaa' })) + applyBoth(pair, (engine) => engine.enqueue('reserve-later', { data: 'bbbb' })) + for (const engine of [pair.reference, pair.queue]) { + engine.setNotification((event) => { + if (event.kind === 'drop' && event.id === 'trigger') { + engine.setActive('reserve-later', true) + } + }) + } + + applyBoth(pair, (engine) => engine.tick()) + + expect(pair.queue.events.slice(-3)).toEqual([ + { kind: 'drop', id: 'trigger', data: 'drop' }, + { kind: 'data', id: 'active-before', data: 'aaaa', rawLength: 4 }, + { kind: 'data', id: 'reserve-later', data: 'bbbb', rawLength: 4 } + ]) + }) + + it('freezes lane order while an active candidate loses live reserve', () => { + const pair = createPair() + applyBoth(pair, (engine) => + engine.enqueue('credit-a', { data: 'aaaaaaaa', rawLength: 8, transformed: true }) + ) + applyBoth(pair, (engine) => + engine.enqueue('credit-b', { data: 'bbbb', rawLength: 4, transformed: true }) + ) + applyBoth(pair, (engine) => engine.tick()) + applyBoth(pair, (engine) => engine.setActive('trigger', true)) + applyBoth(pair, (engine) => engine.setActive('reserve-later', true)) + applyBoth(pair, (engine) => engine.setDroppable('trigger', true)) + applyBoth(pair, (engine) => engine.enqueue('trigger', { data: 'drop' })) + applyBoth(pair, (engine) => engine.enqueue('reserve-later', { data: 'held' })) + for (const engine of [pair.reference, pair.queue]) { + engine.setNotification((event) => { + if (event.kind === 'drop' && event.id === 'trigger') { + engine.setActive('reserve-later', false) + } + }) + } + + applyBoth(pair, (engine) => engine.tick()) + + expect(dataEvents(pair.queue).some((event) => event.id === 'reserve-later')).toBe(false) + expect(pair.queue.snapshot()).toMatchObject({ + pending: [['reserve-later', { data: 'held' }]], + scheduledDelay: 0 + }) + applyBoth(pair, (engine) => engine.tick()) + expect(pair.queue.debugQueue().blockedSize).toBe(1) + }) + + it('orders final payloads before exit and only wakes blocked work for net-new credit', () => { + const pair = createPair() + applyBoth(pair, (engine) => + engine.enqueue('credit-a', { data: 'aaaaaaaa', rawLength: 8, transformed: true }) + ) + applyBoth(pair, (engine) => + engine.enqueue('credit-b', { data: 'bbbb', rawLength: 4, transformed: true }) + ) + applyBoth(pair, (engine) => engine.tick()) + applyBoth(pair, (engine) => engine.enqueue('final', { data: 'tail' })) + applyBoth(pair, (engine) => engine.enqueue('held', { data: 'held' })) + applyBoth(pair, (engine) => engine.tick()) + + const eventStart = pair.queue.events.length + const rebuildsBeforeNoopExit = pair.queue.debugQueue().laneRebuildCount + applyBoth(pair, (engine) => engine.exit('final')) + expect(pair.queue.events.slice(eventStart)).toEqual([ + { kind: 'data', id: 'final', data: 'tail', rawLength: 4 }, + { kind: 'exit', id: 'final' } + ]) + expect(pair.queue.snapshot()).toMatchObject({ + totalInFlight: TOTAL_LIMIT, + flowPending: [['held', 4]], + scheduledDelay: null + }) + expect(pair.queue.debugQueue().laneRebuildCount).toBe(rebuildsBeforeNoopExit) + + pair.reference.exit('credit-a') + pair.queue.exit('credit-a') + expect(pair.reference.snapshot().scheduledDelay).toBeNull() + expect(pair.queue.snapshot().scheduledDelay).toBe(0) + expect(pair.queue.snapshot()).toMatchObject({ + pending: pair.reference.snapshot().pending, + accounting: pair.reference.snapshot().accounting, + totalInFlight: pair.reference.snapshot().totalInFlight, + flowPending: pair.reference.snapshot().flowPending, + events: pair.reference.snapshot().events + }) + }) + + it('coalesces zero-write reentrant exit credit into a post-round wakeup', () => { + const pair = createPair() + applyBoth(pair, (engine) => + engine.enqueue('credit-a', { data: 'aaaaaaaa', rawLength: 8, transformed: true }) + ) + applyBoth(pair, (engine) => + engine.enqueue('credit-b', { data: 'bbbb', rawLength: 4, transformed: true }) + ) + applyBoth(pair, (engine) => engine.tick()) + applyBoth(pair, (engine) => engine.enqueue('held', { data: 'held' })) + applyBoth(pair, (engine) => engine.enqueue('hidden', { data: 'drop' })) + applyBoth(pair, (engine) => engine.setDroppable('hidden', true)) + for (const engine of [pair.reference, pair.queue]) { + let exited = false + engine.setNotification((event) => { + if (!exited && event.kind === 'drop' && event.id === 'hidden') { + exited = true + engine.exit('credit-a') + } + }) + } + + pair.reference.tick() + pair.queue.tick() + + expect(pair.reference.snapshot().scheduledDelay).toBeNull() + expect(pair.queue.snapshot().scheduledDelay).toBe(0) + expect(pair.queue.snapshot()).toMatchObject({ + pending: pair.reference.snapshot().pending, + accounting: pair.reference.snapshot().accounting, + totalInFlight: pair.reference.snapshot().totalInFlight, + flowPending: pair.reference.snapshot().flowPending, + events: pair.reference.snapshot().events + }) + pair.queue.tick() + expect(dataEvents(pair.queue).at(-1)).toMatchObject({ id: 'held', data: 'held' }) + }) + + it('preserves dropped sentinel payloads before exit without leaving its timer armed', () => { + const pair = createPair() + applyBoth(pair, (engine) => engine.enqueue('sentinel', { data: 'query', droppedOutput: true })) + applyBoth(pair, (engine) => engine.exit('sentinel')) + expect(pair.queue.events).toEqual([ + { + kind: 'data', + id: 'sentinel', + data: 'query', + rawLength: 5, + droppedOutput: true + }, + { kind: 'exit', id: 'sentinel' } + ]) + expect(pair.queue.snapshot()).toMatchObject({ + pending: [], + scheduledDelay: null, + totalInFlight: 0 + }) + }) + + it('matches notification-reentrant exit and clear ordering', () => { + const exitPair = createPair() + applyBoth(exitPair, (engine) => engine.enqueue('partial', { data: 'abcdefgh' })) + applyBoth(exitPair, (engine) => engine.enqueue('next', { data: 'next' })) + for (const engine of [exitPair.reference, exitPair.queue]) { + let exited = false + engine.setNotification((event) => { + if (!exited && event.kind === 'data' && event.id === 'partial') { + exited = true + engine.exit('partial') + } + }) + } + applyBoth(exitPair, (engine) => engine.tick()) + expect(exitPair.queue.events).toEqual([ + { kind: 'tick', delayMs: 2 }, + { kind: 'data', id: 'partial', data: 'abcd', rawLength: 4 }, + { kind: 'data', id: 'partial', data: 'efgh', rawLength: 4 }, + { kind: 'exit', id: 'partial' }, + { kind: 'data', id: 'next', data: 'next', rawLength: 4 } + ]) + expect(exitPair.queue.snapshot()).toMatchObject({ + pending: [], + flowPending: [], + scheduledDelay: null + }) + + const clearPair = createPair() + applyBoth(clearPair, (engine) => engine.enqueue('first', { data: 'abcdefgh' })) + applyBoth(clearPair, (engine) => engine.enqueue('detached', { data: 'detached' })) + for (const engine of [clearPair.reference, clearPair.queue]) { + let cleared = false + engine.setNotification((event) => { + if (!cleared && event.kind === 'data') { + cleared = true + engine.clear() + } + }) + } + applyBoth(clearPair, (engine) => engine.tick()) + expect(clearPair.queue.events).toEqual([ + { kind: 'tick', delayMs: 2 }, + { kind: 'data', id: 'first', data: 'abcd', rawLength: 4 }, + { kind: 'clear' } + ]) + expect(clearPair.queue.snapshot()).toMatchObject({ + pending: [], + accounting: [], + flowPending: [], + scheduledDelay: null, + totalInFlight: 0 + }) + expect(clearPair.queue.debugQueue()).toMatchObject({ + activeHeadId: null, + activeTailId: null, + backgroundHeadId: null, + backgroundTailId: null, + blockedHeadId: null, + blockedTailId: null, + openRound: null + }) + }) +}) diff --git a/src/main/ipc/pty-renderer-delivery-credit.test.ts b/src/main/ipc/pty-renderer-delivery-credit.test.ts new file mode 100644 index 00000000000..af1d2bb8ed7 --- /dev/null +++ b/src/main/ipc/pty-renderer-delivery-credit.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest' +import { + appendPtyDeliveryCredit, + MAX_PENDING_PTY_DELIVERY_CREDIT_SPANS, + MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS, + MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS_PER_PTY, + MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES, + MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES, + PtyRendererDeliveryCreditLedger, + settlePtyDeliveryCredit, + takePtyDeliveryCredit +} from './pty-renderer-delivery-credit' + +function makeCredit(charCount: number) { + return { charCount, acknowledge: vi.fn() } +} + +describe('PTY renderer delivery credit', () => { + it('splits queued provider credit without settling bytes left for a later renderer chunk', () => { + const first = makeCredit(5) + const second = makeCredit(7) + const spans = appendPtyDeliveryCredit(appendPtyDeliveryCredit(undefined, first), second) + + const taken = takePtyDeliveryCredit(spans, 8) + settlePtyDeliveryCredit(taken) + + expect(first.acknowledge).toHaveBeenCalledWith(5) + expect(second.acknowledge).toHaveBeenCalledWith(3) + expect(spans).toEqual([{ credit: second, chars: 4 }]) + }) + + it('settles explicit provider owners and legacy fallback spans in renderer order', () => { + const first = makeCredit(5) + const second = makeCredit(4) + const acknowledgeFallback = vi.fn() + const ledger = new PtyRendererDeliveryCreditLedger() + + ledger.recordSent('pty-1', 5, [{ credit: first, chars: 5 }], acknowledgeFallback) + ledger.recordSent('pty-1', 3, undefined, acknowledgeFallback) + ledger.recordSent('pty-1', 4, [{ credit: second, chars: 4 }], acknowledgeFallback) + ledger.acknowledge('pty-1', 7, acknowledgeFallback) + ledger.acknowledge('pty-1', 2, acknowledgeFallback) + ledger.acknowledge('pty-1', 3, acknowledgeFallback) + + expect(first.acknowledge).toHaveBeenCalledWith(5) + expect(second.acknowledge.mock.calls).toEqual([[1], [3]]) + expect(acknowledgeFallback.mock.calls).toEqual([[2], [1]]) + }) + + it('writes off every captured owner while keeping PTY fallback routing separate', () => { + const first = makeCredit(4) + const second = makeCredit(6) + const acknowledgeFallback = vi.fn() + const ledger = new PtyRendererDeliveryCreditLedger() + + ledger.recordSent('pty-1', 4, [{ credit: first, chars: 4 }], acknowledgeFallback) + ledger.recordSent('pty-2', 2, undefined, acknowledgeFallback) + ledger.recordSent('pty-3', 6, [{ credit: second, chars: 6 }], acknowledgeFallback) + ledger.writeOffAll(acknowledgeFallback) + + expect(first.acknowledge).toHaveBeenCalledWith(4) + expect(second.acknowledge).toHaveBeenCalledWith(6) + expect(acknowledgeFallback).toHaveBeenCalledOnce() + expect(acknowledgeFallback).toHaveBeenCalledWith('pty-2', 2) + }) + + it('abandons an exited PTY without crediting a replacement owner', () => { + const exited = makeCredit(4) + const acknowledgeFallback = vi.fn() + const ledger = new PtyRendererDeliveryCreditLedger() + ledger.recordSent('reused-id', 4, [{ credit: exited, chars: 4 }], acknowledgeFallback) + + ledger.abandon('reused-id') + ledger.acknowledge('reused-id', 4, acknowledgeFallback) + + expect(exited.acknowledge).not.toHaveBeenCalled() + expect(acknowledgeFallback).not.toHaveBeenCalled() + }) + + it('collapses one-byte pending credit floods into already-settled metadata', () => { + const total = MAX_PENDING_PTY_DELIVERY_CREDIT_SPANS + 100 + const credits = Array.from({ length: total }, () => makeCredit(1)) + let spans + for (const credit of credits) { + spans = appendPtyDeliveryCredit(spans, credit) + } + + expect(spans).toEqual([{ chars: total, settled: true }]) + expect(credits.every((credit) => credit.acknowledge.mock.calls.length === 1)).toBe(true) + + const ledger = new PtyRendererDeliveryCreditLedger() + const acknowledgeFallback = vi.fn() + const sent = takePtyDeliveryCredit(spans, total) + ledger.recordSent('pty-tiny', total, sent, acknowledgeFallback) + ledger.acknowledge('pty-tiny', total, acknowledgeFallback) + + expect(acknowledgeFallback).not.toHaveBeenCalled() + expect(credits.every((credit) => credit.acknowledge.mock.calls.length === 1)).toBe(true) + }) + + it('bounds per-PTY in-flight credit closures and settles each exactly once', () => { + const total = MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS_PER_PTY + 100 + const credits = Array.from({ length: total }, () => makeCredit(1)) + const acknowledgeFallback = vi.fn() + const ledger = new PtyRendererDeliveryCreditLedger() + + for (const credit of credits) { + ledger.recordSent('pty-tiny', 1, [{ credit, chars: 1 }], acknowledgeFallback) + } + + const state = (Reflect.get(ledger, 'inFlightByPty') as Map).get( + 'pty-tiny' + ) + expect(state?.spans.length).toBeLessThanOrEqual(MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS_PER_PTY) + ledger.acknowledge('pty-tiny', total, acknowledgeFallback) + + expect(acknowledgeFallback).not.toHaveBeenCalled() + expect(credits.every((credit) => credit.acknowledge.mock.calls.length === 1)).toBe(true) + }) + + it('bounds aggregate in-flight credit spans across PTYs', () => { + let acknowledged = 0 + const acknowledgeFallback = vi.fn() + const ledger = new PtyRendererDeliveryCreditLedger() + const total = MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS + 100 + + for (let index = 0; index < total; index += 1) { + const credit = { + charCount: 1, + acknowledge: (chars: number) => { + acknowledged += chars + } + } + ledger.recordSent(`pty-${index % 8}`, 1, [{ credit, chars: 1 }], acknowledgeFallback) + } + + expect(Reflect.get(ledger, 'retainedSpanCount')).toBeLessThanOrEqual( + MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS + ) + ledger.writeOffAll(acknowledgeFallback) + expect(acknowledged).toBe(total) + expect(acknowledgeFallback).not.toHaveBeenCalled() + }) + + it('bounds unique in-flight IDs and settles evicted credit exactly once', () => { + const credits = Array.from({ length: MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES + 50 }, () => + makeCredit(1) + ) + const acknowledgeFallback = vi.fn() + const ledger = new PtyRendererDeliveryCreditLedger() + + for (const [index, credit] of credits.entries()) { + ledger.recordSent(`pty-${index}`, 1, [{ credit, chars: 1 }], acknowledgeFallback) + } + + const states = Reflect.get(ledger, 'inFlightByPty') as Map + expect(states.size).toBe(MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES) + expect(states.has('pty-0')).toBe(false) + ledger.writeOffAll(acknowledgeFallback) + expect(credits.every((credit) => credit.acknowledge.mock.calls.length === 1)).toBe(true) + expect(acknowledgeFallback).not.toHaveBeenCalled() + }) + + it('bounds aggregate in-flight ID bytes and settles fallback before eviction', () => { + const id = 'x'.repeat(MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES) + const settleFallback = vi.fn() + const ledger = new PtyRendererDeliveryCreditLedger() + + ledger.recordSent(id, 3, undefined, settleFallback) + ledger.recordSent('extra', 2, undefined, settleFallback) + + const states = Reflect.get(ledger, 'inFlightByPty') as Map + expect(states.size).toBe(1) + expect(states.has(id)).toBe(false) + expect(settleFallback).toHaveBeenCalledOnce() + expect(settleFallback).toHaveBeenCalledWith(3) + ledger.acknowledge(id, 3, settleFallback) + expect(settleFallback).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/pty-renderer-delivery-credit.ts b/src/main/ipc/pty-renderer-delivery-credit.ts new file mode 100644 index 00000000000..a6c0325cf64 --- /dev/null +++ b/src/main/ipc/pty-renderer-delivery-credit.ts @@ -0,0 +1,297 @@ +import type { PtyDataUpstreamCredit } from '../providers/pty-provider-events' + +export const MAX_PENDING_PTY_DELIVERY_CREDIT_SPANS = 4096 +export const MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS_PER_PTY = 4096 +export const MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS = 16_384 +export const MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES = 4096 +export const MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES = 8 * 1024 * 1024 + +export type PtyDeliveryCreditSpan = + | { + credit: PtyDataUpstreamCredit + chars: number + settled?: never + } + | { + chars: number + settled: true + credit?: never + } + +type InFlightCreditSpan = { + chars: number + credit?: PtyDataUpstreamCredit + settled?: true +} + +type InFlightCreditState = { + idBytes: number + spans: InFlightCreditSpan[] + settledPrefixChars: number + settleFallback: (chars: number) => void +} + +export function appendPtyDeliveryCredit( + spans: PtyDeliveryCreditSpan[] | undefined, + credit: PtyDataUpstreamCredit | undefined +): PtyDeliveryCreditSpan[] | undefined { + if (!credit || !Number.isFinite(credit.charCount) || credit.charCount <= 0) { + return spans + } + const chars = Math.floor(credit.charCount) + const next = spans ?? [] + const tail = next.at(-1) + if (tail?.settled === true) { + credit.acknowledge(chars) + tail.chars += chars + return next + } + if (next.length >= MAX_PENDING_PTY_DELIVERY_CREDIT_SPANS) { + let settledChars = chars + for (const span of next) { + settledChars += span.chars + span.credit?.acknowledge(span.chars) + } + credit.acknowledge(chars) + next.splice(0, next.length, { chars: settledChars, settled: true }) + return next + } + next.push({ credit, chars }) + return next +} + +export function takePtyDeliveryCredit( + spans: PtyDeliveryCreditSpan[] | undefined, + requestedChars: number +): PtyDeliveryCreditSpan[] | undefined { + if (!spans || spans.length === 0 || requestedChars <= 0) { + return undefined + } + const taken: PtyDeliveryCreditSpan[] = [] + let remaining = requestedChars + while (remaining > 0 && spans.length > 0) { + const span = spans[0] + const chars = Math.min(remaining, span.chars) + taken.push(span.settled === true ? { chars, settled: true } : { credit: span.credit, chars }) + span.chars -= chars + remaining -= chars + if (span.chars === 0) { + spans.shift() + } + } + return taken +} + +export function settlePtyDeliveryCredit(spans: PtyDeliveryCreditSpan[] | undefined): void { + if (!spans) { + return + } + for (const span of spans.splice(0)) { + span.credit?.acknowledge(span.chars) + } +} + +export class PtyRendererDeliveryCreditLedger { + private readonly inFlightByPty = new Map() + private retainedSpanCount = 0 + private retainedStateIdBytes = 0 + + recordSent( + id: string, + charCount: number, + explicitCredit: PtyDeliveryCreditSpan[] | undefined, + settleFallback: (chars: number) => void + ): void { + if (!Number.isFinite(charCount) || charCount <= 0) { + return + } + const admittedCharCount = Math.floor(charCount) + const state = this.inFlightByPty.get(id) ?? { + idBytes: Buffer.byteLength(id, 'utf8'), + spans: [], + settledPrefixChars: 0, + settleFallback + } + const isNewState = !this.inFlightByPty.has(id) + state.settleFallback = settleFallback + let recorded = 0 + for (const span of explicitCredit ?? []) { + if (recorded >= admittedCharCount || !Number.isFinite(span.chars) || span.chars <= 0) { + break + } + const chars = Math.min(Math.floor(span.chars), admittedCharCount - recorded) + this.appendSpan( + state, + span.settled === true ? { chars, settled: true } : { chars, credit: span.credit } + ) + recorded += chars + } + if (recorded < admittedCharCount) { + this.appendSpan(state, { chars: admittedCharCount - recorded }) + } + this.inFlightByPty.delete(id) + this.inFlightByPty.set(id, state) + if (isNewState) { + this.retainedStateIdBytes += state.idBytes + } + this.capStates() + } + + acknowledge(id: string, charCount: number, acknowledgeFallback: (chars: number) => void): void { + const state = this.inFlightByPty.get(id) + if (!state || !Number.isFinite(charCount) || charCount <= 0) { + return + } + let remaining = Math.floor(charCount) + const settledPrefix = Math.min(remaining, state.settledPrefixChars) + state.settledPrefixChars -= settledPrefix + remaining -= settledPrefix + let fallbackChars = 0 + while (remaining > 0 && state.spans.length > 0) { + const span = state.spans[0] + const chars = Math.min(remaining, span.chars) + if (span.credit) { + span.credit.acknowledge(chars) + } else if (span.settled !== true) { + fallbackChars += chars + } + span.chars -= chars + remaining -= chars + if (span.chars === 0) { + state.spans.shift() + this.retainedSpanCount-- + } + } + if (state.spans.length === 0 && state.settledPrefixChars === 0) { + this.deleteState(id, state) + } + if (fallbackChars > 0) { + acknowledgeFallback(fallbackChars) + } + } + + writeOff(id: string, acknowledgeFallback: (chars: number) => void): void { + const state = this.inFlightByPty.get(id) + if (!state) { + return + } + this.acknowledge( + id, + state.settledPrefixChars + state.spans.reduce((total, span) => total + span.chars, 0), + acknowledgeFallback + ) + } + + writeOffAll(acknowledgeFallback: (id: string, chars: number) => void): void { + for (const id of Array.from(this.inFlightByPty.keys())) { + this.writeOff(id, (chars) => acknowledgeFallback(id, chars)) + } + } + + abandon(id: string): void { + const state = this.inFlightByPty.get(id) + if (!state) { + return + } + this.retainedSpanCount -= state.spans.length + this.deleteState(id, state) + } + + private appendSpan(state: InFlightCreditState, span: InFlightCreditSpan): void { + if (span.chars <= 0) { + return + } + let tail = state.spans.at(-1) + if (tail && this.canMergeSpans(tail, span)) { + tail.chars += span.chars + return + } + this.ensureSpanCapacity(state) + tail = state.spans.at(-1) + if (tail && this.canMergeSpans(tail, span)) { + tail.chars += span.chars + return + } + state.spans.push(span) + this.retainedSpanCount++ + } + + private capStates(): void { + while ( + this.inFlightByPty.size > MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES || + this.retainedStateIdBytes > MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES + ) { + const oldest = this.inFlightByPty.entries().next().value as + | [string, InFlightCreditState] + | undefined + if (!oldest) { + return + } + this.collapseState(oldest[1]) + this.deleteState(oldest[0], oldest[1]) + } + } + + private ensureSpanCapacity(state: InFlightCreditState): void { + if ( + state.spans.length >= MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS_PER_PTY || + this.retainedSpanCount >= MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS + ) { + this.collapseState(state) + } + while (this.retainedSpanCount >= MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS) { + const candidate = Array.from(this.inFlightByPty.values()).find( + (entry) => entry.spans.length > 0 + ) + if (!candidate) { + break + } + this.collapseState(candidate) + } + } + + private collapseState(state: InFlightCreditState): void { + if (state.spans.length === 0) { + return + } + let settledChars = 0 + let fallbackChars = 0 + for (const span of state.spans) { + settledChars += span.chars + try { + if (span.credit) { + span.credit.acknowledge(span.chars) + } else if (span.settled !== true) { + fallbackChars += span.chars + } + } catch { + // Why: overload shedding must still release local metadata if an upstream is gone. + } + } + if (fallbackChars > 0) { + try { + state.settleFallback(fallbackChars) + } catch { + // Why: overload shedding must still release local metadata if an upstream is gone. + } + } + state.settledPrefixChars += settledChars + this.retainedSpanCount -= state.spans.length + state.spans.length = 0 + } + + private deleteState(id: string, state: InFlightCreditState): void { + if (this.inFlightByPty.get(id) !== state) { + return + } + this.inFlightByPty.delete(id) + this.retainedStateIdBytes -= state.idBytes + } + + private canMergeSpans(left: InFlightCreditSpan, right: InFlightCreditSpan): boolean { + if (left.credit || right.credit) { + return left.credit !== undefined && left.credit === right.credit + } + return left.settled === right.settled + } +} diff --git a/src/main/ipc/pty-renderer-delivery-retention.test.ts b/src/main/ipc/pty-renderer-delivery-retention.test.ts new file mode 100644 index 00000000000..3af40ac3401 --- /dev/null +++ b/src/main/ipc/pty-renderer-delivery-retention.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MAX_PENDING_PTY_DATA_CHARS, + MAX_PENDING_PTY_DATA_CREDIT_SPANS, + MAX_PENDING_PTY_DATA_ID_BYTES, + MAX_PENDING_PTY_DATA_STATES, + MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_ID_BYTES, + MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES, + PendingPtyDataMap, + PtyRendererDeliveryAccountingMap, + PtyRendererDeliveryIdSet, + settleRejectedPtyRendererDelivery +} from './pty-renderer-delivery-retention' + +type Pending = { + data: string + creditSpans: number +} + +function pendingMap(onRejected = vi.fn()) { + return { + map: new PendingPtyDataMap( + (pending) => ({ chars: pending.data.length, creditSpans: pending.creditSpans }), + onRejected + ), + onRejected + } +} + +describe('PTY renderer delivery retention', () => { + it('preserves ordinary pending updates and exact map bookkeeping', () => { + const { map, onRejected } = pendingMap() + + expect(map.admit('pty-1', { data: 'one', creditSpans: 1 })).toBe(true) + expect(map.admit('pty-1', { data: 'updated', creditSpans: 2 })).toBe(true) + expect(map.delete('pty-1')).toBe(true) + + expect(map.size).toBe(0) + expect(onRejected).not.toHaveBeenCalled() + }) + + it('rejects unique pending-ID floods after the bounded state count', () => { + const { map, onRejected } = pendingMap() + for (let index = 0; index < MAX_PENDING_PTY_DATA_STATES + 50; index += 1) { + map.set(`pty-${index}`, { data: 'x', creditSpans: 1 }) + } + + expect(map.size).toBe(MAX_PENDING_PTY_DATA_STATES) + expect(map.has('pty-0')).toBe(true) + expect(map.has(`pty-${MAX_PENDING_PTY_DATA_STATES}`)).toBe(false) + expect(onRejected).toHaveBeenCalledTimes(50) + }) + + it('bounds pending ID bytes, characters, and credit records independently', () => { + const idPressure = pendingMap() + const id = 'x'.repeat(MAX_PENDING_PTY_DATA_ID_BYTES) + idPressure.map.set(id, { data: 'x', creditSpans: 0 }) + idPressure.map.set('extra', { data: 'x', creditSpans: 0 }) + expect(idPressure.map.size).toBe(1) + expect(idPressure.onRejected).toHaveBeenCalledWith('extra', { + data: 'x', + creditSpans: 0 + }) + + const charPressure = pendingMap() + charPressure.map.set('full', { + data: 'x'.repeat(MAX_PENDING_PTY_DATA_CHARS), + creditSpans: 0 + }) + charPressure.map.set('extra', { data: 'x', creditSpans: 0 }) + expect(charPressure.map.size).toBe(1) + expect(charPressure.onRejected).toHaveBeenCalledOnce() + + const creditPressure = pendingMap() + creditPressure.map.set('full', { data: 'x', creditSpans: MAX_PENDING_PTY_DATA_CREDIT_SPANS }) + creditPressure.map.set('extra', { data: 'x', creditSpans: 1 }) + expect(creditPressure.map.size).toBe(1) + expect(creditPressure.onRejected).toHaveBeenCalledOnce() + }) + + it('bounds accounting and warning IDs without evicting admitted live state', () => { + const accounting = new PtyRendererDeliveryAccountingMap() + const warned = new PtyRendererDeliveryIdSet() + for (let index = 0; index < MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES + 50; index += 1) { + accounting.set(`pty-${index}`, index) + warned.add(`pty-${index}`) + } + + expect(accounting.size).toBe(MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES) + expect(warned.size).toBe(MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES) + expect(accounting.has('pty-0')).toBe(true) + expect(accounting.has(`pty-${MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES}`)).toBe(false) + expect(warned.has(`pty-${MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES}`)).toBe(false) + + const byteBounded = new PtyRendererDeliveryAccountingMap() + byteBounded.set('x'.repeat(MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_ID_BYTES), 1) + expect(byteBounded.admit('extra', 2)).toBe(false) + expect(byteBounded.size).toBe(1) + }) + + it('settles rejected explicit, already-settled, and fallback credit exactly once', () => { + const explicit = vi.fn() + const fallback = vi.fn() + + settleRejectedPtyRendererDelivery( + 7, + [ + { chars: 2, settled: true }, + { chars: 3, credit: { charCount: 3, acknowledge: explicit } } + ], + fallback + ) + + expect(explicit).toHaveBeenCalledOnce() + expect(explicit).toHaveBeenCalledWith(3) + expect(fallback).toHaveBeenCalledOnce() + expect(fallback).toHaveBeenCalledWith(2) + }) +}) diff --git a/src/main/ipc/pty-renderer-delivery-retention.ts b/src/main/ipc/pty-renderer-delivery-retention.ts new file mode 100644 index 00000000000..71efeb1f0fa --- /dev/null +++ b/src/main/ipc/pty-renderer-delivery-retention.ts @@ -0,0 +1,188 @@ +import { + MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES, + MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES, + type PtyDeliveryCreditSpan +} from './pty-renderer-delivery-credit' + +export const MAX_PENDING_PTY_DATA_STATES = MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES +export const MAX_PENDING_PTY_DATA_ID_BYTES = MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES +export const MAX_PENDING_PTY_DATA_CHARS = 32 * 1024 * 1024 +export const MAX_PENDING_PTY_DATA_CREDIT_SPANS = 16_384 +export const MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES = MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES +export const MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_ID_BYTES = + MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES + +type PendingPtyDataMeasurement = { + chars: number + creditSpans: number +} + +export class PendingPtyDataMap extends Map { + private readonly measurements = new Map() + private retainedChars = 0 + private retainedCreditSpans = 0 + private retainedIdBytes = 0 + + constructor( + private readonly measure: (value: T) => PendingPtyDataMeasurement, + private readonly onRejected: (id: string, value: T) => void + ) { + super() + } + + admit(id: string, value: T): boolean { + const hadPrevious = super.has(id) + const previousMeasurement = this.measurements.get(id) ?? { chars: 0, creditSpans: 0 } + const measurement = this.measure(value) + const idBytes = Buffer.byteLength(id, 'utf8') + const nextSize = this.size + (hadPrevious ? 0 : 1) + const nextIdBytes = this.retainedIdBytes + (hadPrevious ? 0 : idBytes) + const nextChars = this.retainedChars - previousMeasurement.chars + measurement.chars + const nextCreditSpans = + this.retainedCreditSpans - previousMeasurement.creditSpans + measurement.creditSpans + + if ( + nextSize > MAX_PENDING_PTY_DATA_STATES || + nextIdBytes > MAX_PENDING_PTY_DATA_ID_BYTES || + nextChars > MAX_PENDING_PTY_DATA_CHARS || + nextCreditSpans > MAX_PENDING_PTY_DATA_CREDIT_SPANS + ) { + if (hadPrevious) { + this.delete(id) + } + this.onRejected(id, value) + return false + } + + super.set(id, value) + this.measurements.set(id, measurement) + this.retainedChars = nextChars + this.retainedCreditSpans = nextCreditSpans + this.retainedIdBytes = nextIdBytes + return true + } + + override set(id: string, value: T): this { + this.admit(id, value) + return this + } + + override delete(id: string): boolean { + const measurement = this.measurements.get(id) ?? { chars: 0, creditSpans: 0 } + if (!super.delete(id)) { + return false + } + this.measurements.delete(id) + this.retainedChars -= measurement.chars + this.retainedCreditSpans -= measurement.creditSpans + this.retainedIdBytes -= Buffer.byteLength(id, 'utf8') + return true + } + + override clear(): void { + super.clear() + this.measurements.clear() + this.retainedChars = 0 + this.retainedCreditSpans = 0 + this.retainedIdBytes = 0 + } +} + +export class PtyRendererDeliveryAccountingMap extends Map { + private retainedIdBytes = 0 + + admit(id: string, value: T): boolean { + if (super.has(id)) { + super.set(id, value) + return true + } + const idBytes = Buffer.byteLength(id, 'utf8') + if ( + this.size >= MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES || + this.retainedIdBytes + idBytes > MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_ID_BYTES + ) { + return false + } + super.set(id, value) + this.retainedIdBytes += idBytes + return true + } + + override set(id: string, value: T): this { + this.admit(id, value) + return this + } + + override delete(id: string): boolean { + if (!super.delete(id)) { + return false + } + this.retainedIdBytes -= Buffer.byteLength(id, 'utf8') + return true + } + + override clear(): void { + super.clear() + this.retainedIdBytes = 0 + } +} + +export class PtyRendererDeliveryIdSet extends Set { + private retainedIdBytes = 0 + + remember(id: string): boolean { + if (super.has(id)) { + return false + } + const idBytes = Buffer.byteLength(id, 'utf8') + if ( + this.size >= MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES || + this.retainedIdBytes + idBytes > MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_ID_BYTES + ) { + return false + } + super.add(id) + this.retainedIdBytes += idBytes + return true + } + + override add(id: string): this { + this.remember(id) + return this + } + + override delete(id: string): boolean { + if (!super.delete(id)) { + return false + } + this.retainedIdBytes -= Buffer.byteLength(id, 'utf8') + return true + } + + override clear(): void { + super.clear() + this.retainedIdBytes = 0 + } +} + +export function settleRejectedPtyRendererDelivery( + charCount: number, + creditSpans: PtyDeliveryCreditSpan[] | undefined, + settleFallback: (chars: number) => void +): void { + if (!Number.isFinite(charCount) || charCount <= 0) { + return + } + let remaining = Math.floor(charCount) + for (const span of creditSpans ?? []) { + if (remaining <= 0 || !Number.isFinite(span.chars) || span.chars <= 0) { + continue + } + const chars = Math.min(remaining, Math.floor(span.chars)) + remaining -= chars + span.credit?.acknowledge(chars) + } + if (remaining > 0) { + settleFallback(remaining) + } +} diff --git a/src/main/ipc/pty-renderer-delivery-router.ts b/src/main/ipc/pty-renderer-delivery-router.ts new file mode 100644 index 00000000000..1cffa26bb5f --- /dev/null +++ b/src/main/ipc/pty-renderer-delivery-router.ts @@ -0,0 +1,35 @@ +import type { PtyDataEvent } from '../providers/pty-provider-events' + +export type ExternalPtyRendererDeliveryRouter = { + data(payload: PtyDataEvent): void + replay(payload: { id: string; data: string }): void + exit(payload: { id: string; code: number }): void +} + +function returnUnroutedCredit(payload: PtyDataEvent): void { + payload.upstreamCredit?.acknowledge(payload.upstreamCredit.charCount) +} + +let router: ExternalPtyRendererDeliveryRouter = { + data: returnUnroutedCredit, + replay: () => {}, + exit: () => {} +} + +export function installExternalPtyRendererDeliveryRouter( + nextRouter: ExternalPtyRendererDeliveryRouter +): void { + router = nextRouter +} + +export function routeExternalPtyData(payload: PtyDataEvent): void { + router.data(payload) +} + +export function routeExternalPtyReplay(payload: { id: string; data: string }): void { + router.replay(payload) +} + +export function routeExternalPtyExit(payload: { id: string; code: number }): void { + router.exit(payload) +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 0473381c620..6c84f5f5bd3 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -11,6 +11,8 @@ import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import type { TuiAgent } from '../../shared/types' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' +import type * as NodeBoundedFileReader from '../../shared/memory-safety/node-bounded-file-reader' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -126,6 +128,21 @@ vi.mock('fs', () => ({ } })) +vi.mock('../../shared/memory-safety/node-bounded-file-reader', async (importOriginal) => ({ + ...(await importOriginal()), + readNodeFileSyncWithinLimit: (path: string, maxBytes: number) => { + const content = readFileSyncMock(path) + if (typeof content !== 'string' && !Buffer.isBuffer(content)) { + throw new Error('File unavailable') + } + const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content) + if (buffer.byteLength > maxBytes) { + throw new Error('File too large') + } + return { buffer, stats: { size: buffer.byteLength } } + } +})) + vi.mock('node-pty', () => ({ spawn: spawnMock })) @@ -230,6 +247,7 @@ import { } from '../providers/ssh-pty-errors' import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args' import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { acquireWatcherRemovalGate } from './watcher-removal-gate' // Why: Windows resolves a bare PowerShell name to an absolute exe before ConPTY, else CreateProcessW fails with error 5 (PR #6537 / #5161). @@ -1018,6 +1036,52 @@ describe('registerPtyHandlers', () => { clearProviderPtyState(owner.ptyId) }) + it('fails claimed ensure closed before aggregate owner listings amplify memory', async () => { + const ownersPerSession = 256 + const sessionCount = Math.floor(MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES / ownersPerSession) + 1 + const sessions = Array.from({ length: sessionCount }, (_, sessionIndex) => { + const id = `pty-owner-cap-${sessionIndex}` + return { + id, + incarnationId: 'incarnation-owner-cap', + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: Array.from({ length: ownersPerSession }, (_, ownerIndex) => { + const index = sessionIndex * ownersPerSession + ownerIndex + return { + claim: { + ...recoveredAgentClaim, + identityDigest: index.toString(36).padStart(43, 'a') + }, + generation: `generation-owner-cap-${index}`, + phase: 'live' as const, + ptyId: id, + surface: recoveredAgentSurface + } + }) + } + }) + const provider = createAgentClaimProvider({ sessions }) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + agentSessionEnsure: { + claim: { + ...recoveredAgentClaim, + identityDigest: '7777777777777777777777777777777777777777777' + }, + surface: recoveredAgentSurface + } + }) + ).rejects.toThrow('execution_owner_unavailable') + expect(provider.spawn).not.toHaveBeenCalled() + }) + it('releases an adopted-owner fence when that owner exits during admission', async () => { const incarnationId = 'incarnation-adopted-exit' const owner: AgentSessionOwnerBinding = { @@ -2830,6 +2894,40 @@ describe('registerPtyHandlers', () => { } }) + it('drops OPENCODE_CONFIG_DIR for a WSL daemon spawn until the guest overlay is known', async () => { + await withWin32Platform(async () => { + const env = await daemonSpawnAndGetEnv({}, undefined, undefined, undefined, { + shellOverride: 'wsl.exe' + }) + // Why: relay not connected yet → never cross the Windows overlay path into WSL. + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + }) + }) + + it('points OPENCODE_CONFIG_DIR at the guest overlay when the WSL relay reports it', async () => { + const guestDir = '/home/jin/.orca-relay/opencode-overlays/abc' + const spy = vi.spyOn(wslHookRelayManager, 'getOpenCodeOverlayDir').mockReturnValue(guestDir) + try { + await withWin32Platform(async () => { + const env = await daemonSpawnAndGetEnv( + { ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/home/jin/.config/opencode' }, + undefined, + undefined, + undefined, + { shellOverride: 'wsl.exe' } + ) + expect(env.OPENCODE_CONFIG_DIR).toBe(guestDir) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(guestDir) + // The Windows-side source pointer must not cross into the guest. + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + }) + } finally { + spy.mockRestore() + } + }) + it('strips the daemon-inherited Orca-owned CODEX_HOME for real-home routing', async () => { const spawnOptions = await daemonSpawnAndGetOptions( {}, @@ -5870,6 +5968,24 @@ describe('registerPtyHandlers', () => { expect(provider.confirmForegroundProcess).not.toHaveBeenCalled() }) + it('preserves unavailable process inspection results from the provider', async () => { + const inspectProcess = vi.fn(async () => ({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true as const + })) + registerPtyHandlers(mainWindow as never) + setLocalPtyProvider({ inspectProcess } as never) + + await expect( + handlers.get('pty:inspectProcess')!(null, { id: 'legacy-daemon-pty' }) + ).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + }) + // Why: daemon resize is fire-and-forget, so pty:getSize must report the APPLIED size, not the requested one (Claude-Code split-pane desync). describe('pty:getSize reports applied size, not requested size', () => { function setupProviderWithAppliedSize(args: { @@ -10326,6 +10442,173 @@ describe('registerPtyHandlers', () => { } }) + it('defers reentrant new, unvisited, and same-partial output to the next drain round', async () => { + vi.useFakeTimers() + const firstProc = createMockProc() + const secondProc = createMockProc() + const newProc = createMockProc() + spawnMock.mockReturnValueOnce(firstProc.proc) + spawnMock.mockReturnValueOnce(secondProc.proc) + spawnMock.mockReturnValueOnce(newProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const firstSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const secondSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const newSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const firstChunk = 'x'.repeat(16 * 1024) + const setActiveRendererPty = getPtySetActiveRendererPtyListener() + let reentered = false + mainWindow.webContents.send.mockImplementation( + (channel: string, payload: { id?: string }) => { + if (channel !== 'pty:data' || payload.id !== firstSpawn.id || reentered) { + return + } + reentered = true + firstProc.emitData('+same') + secondProc.emitData('+append') + newProc.emitData('new') + setActiveRendererPty(null, { id: newSpawn.id, active: true }) + } + ) + + firstProc.emitData(`${firstChunk}tail`) + secondProc.emitData('second') + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toEqual([['pty:data', { id: firstSpawn.id, data: firstChunk }]]) + + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls().slice(1)).toEqual([ + ['pty:data', { id: newSpawn.id, data: 'new' }], + ['pty:data', { id: secondSpawn.id, data: 'second+append' }] + ]) + + vi.advanceTimersByTime(1) + expect(getPtyDataSendCalls().at(-1)).toEqual([ + 'pty:data', + { id: firstSpawn.id, data: 'tail+same' } + ]) + } finally { + vi.useRealTimers() + } + }) + + it('commits a partial remainder before notification-reentrant exit', async () => { + vi.useFakeTimers() + const firstProc = createMockProc() + const secondProc = createMockProc() + spawnMock.mockReturnValueOnce(firstProc.proc).mockReturnValueOnce(secondProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const firstSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string; incarnationId: string } + const secondSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const firstChunk = 'x'.repeat(16 * 1024) + mainWindow.webContents.send.mockClear() + let exited = false + mainWindow.webContents.send.mockImplementation( + (channel: string, payload: { id?: string; data?: string }) => { + if ( + channel === 'pty:data' && + payload.id === firstSpawn.id && + payload.data === firstChunk && + !exited + ) { + exited = true + firstProc.emitExit(0) + } + } + ) + + firstProc.emitData(`${firstChunk}tail`) + secondProc.emitData('next') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send.mock.calls).toEqual([ + ['pty:data', { id: firstSpawn.id, data: firstChunk }], + ['pty:data', { id: firstSpawn.id, data: 'tail' }], + ['pty:exit', { id: firstSpawn.id, code: 0, incarnationId: firstSpawn.incarnationId }], + ['pty:data', { id: secondSpawn.id, data: 'next' }] + ]) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + rendererInFlightPtyCount: 1, + rendererInFlightChars: 'next'.length, + flushScheduled: false + }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('aborts the open drain when renderer lifecycle clear reenters notification', async () => { + vi.useFakeTimers() + const firstProc = createMockProc() + const detachedProc = createMockProc() + spawnMock.mockReturnValueOnce(firstProc.proc).mockReturnValueOnce(detachedProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const firstSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + }) + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + let cleared = false + mainWindow.webContents.send.mockImplementation( + (channel: string, payload: { id?: string }) => { + if (channel === 'pty:data' && payload.id === firstSpawn.id && !cleared) { + cleared = true + handleRendererLoading() + } + } + ) + + firstProc.emitData('first') + detachedProc.emitData('must-not-send') + vi.advanceTimersByTime(2) + + expect(getPtyDataSendCalls()).toEqual([['pty:data', { id: firstSpawn.id, data: 'first' }]]) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + flushScheduled: false, + rendererPtyDispatcherReady: false + }) + expect(vi.getTimerCount()).toBe(1) + } finally { + vi.useRealTimers() + } + }) + it('waits for renderer ACKs before sending more output for a saturated PTY', async () => { vi.useFakeTimers() const firstProc = createMockProc() @@ -10641,7 +10924,8 @@ describe('registerPtyHandlers', () => { registerPtyHandlers(mainWindow as never) mainWindow.webContents.send.mockClear() - provider.emitData('flood-pty', 'x'.repeat(320 * 1024)) + const finalPendingData = 'x'.repeat(320 * 1024) + provider.emitData('flood-pty', finalPendingData) expect(provider.pauseProducer).toHaveBeenCalledTimes(1) // Exit while pending is above the low watermark: the exit path must release the pause, not leave a stale mark. @@ -10653,6 +10937,321 @@ describe('registerPtyHandlers', () => { } }) + it('fences synchronous producer data and duplicate exit while releasing an exiting PTY', () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + const finalPendingData = 'x'.repeat(320 * 1024) + provider.emitData('flood-pty', finalPendingData) + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + provider.resumeProducer.mockImplementation((id: string) => { + provider.emitData(id, 'must-not-follow-exit') + provider.emitExit(id, 0) + }) + + provider.emitExit('flood-pty', 0) + + expect(provider.resumeProducer).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send.mock.calls).toEqual([ + ['pty:data', { id: 'flood-pty', data: finalPendingData }], + ['pty:exit', { id: 'flood-pty', code: 0 }] + ]) + expect( + getPtyDataSendCalls().some( + (call) => (call[1] as { data?: string } | undefined)?.data === 'must-not-follow-exit' + ) + ).toBe(false) + expect( + mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') + ).toHaveLength(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + flushScheduled: false + }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not retry a complete payload after a synchronous renderer send failure', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + let failed = false + let markerFailed = false + mainWindow.webContents.send.mockImplementation( + (channel: string, payload: { id?: string }) => { + if (channel === 'pty:data' && payload.id === 'send-fail-complete' && !failed) { + failed = true + throw new Error('synthetic send failure') + } + if (channel === 'pty:modelRestoreNeeded' && !markerFailed) { + markerFailed = true + throw new Error('synthetic marker failure') + } + } + ) + + provider.emitData('send-fail-complete', 'lost-once') + vi.advanceTimersByTime(2) + + expect(getPtyDataSendCalls()).toEqual([ + ['pty:data', { id: 'send-fail-complete', data: 'lost-once' }] + ]) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + pendingChars: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + flushScheduled: false + }) + expect(vi.getTimerCount()).toBe(0) + + provider.emitData('send-fail-complete', 'recovery') + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toEqual([ + ['pty:data', { id: 'send-fail-complete', data: 'lost-once' }], + ['pty:data', { id: 'send-fail-complete', data: 'recovery' }] + ]) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'send-fail-complete', + reason: 'delivery-heal' + }) + provider.emitData('send-fail-complete', 'after-marker-failure') + vi.advanceTimersByTime(2) + expect( + mainWindow.webContents.send.mock.calls.filter( + (call) => call[0] === 'pty:modelRestoreNeeded' + ) + ).toHaveLength(2) + expect(getPtyDataSendCalls().at(-1)).toEqual([ + 'pty:data', + { id: 'send-fail-complete', data: 'after-marker-failure' } + ]) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('keeps only a partial remainder after a synchronous renderer send failure', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + const firstChunk = 'x'.repeat(16 * 1024) + let failed = false + mainWindow.webContents.send.mockImplementation((channel: string) => { + if (channel === 'pty:data' && !failed) { + failed = true + throw new Error('synthetic send failure') + } + }) + + provider.emitData('send-fail-partial', `${firstChunk}tail`) + vi.advanceTimersByTime(2) + + expect(getPtyDataSendCalls()).toEqual([ + ['pty:data', { id: 'send-fail-partial', data: firstChunk }] + ]) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 4, + rendererInFlightChars: 0, + flushScheduled: true + }) + expect(vi.getTimerCount()).toBe(1) + + vi.advanceTimersByTime(1) + expect(getPtyDataSendCalls()).toEqual([ + ['pty:data', { id: 'send-fail-partial', data: firstChunk }], + ['pty:data', { id: 'send-fail-partial', data: 'tail' }] + ]) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'send-fail-partial', + reason: 'delivery-heal' + }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + rendererInFlightChars: 4, + flushScheduled: false + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('clears failed-delivery restore state when the renderer lifecycle resets', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + const resetRenderer = getMainWindowWebContentsListener('did-start-loading') + const readyRenderer = getPtyRendererDispatcherReadyListener() + let failed = false + mainWindow.webContents.send.mockImplementation((channel: string) => { + if (channel === 'pty:data' && !failed) { + failed = true + throw new Error('synthetic send failure') + } + }) + + provider.emitData('send-fail-reset', 'lost-once') + vi.advanceTimersByTime(2) + resetRenderer() + readyRenderer() + mainWindow.webContents.send.mockClear() + provider.emitData('send-fail-reset', 'repainted-page-data') + vi.advanceTimersByTime(2) + + expect(getPtyDataSendCalls()).toEqual([ + ['pty:data', { id: 'send-fail-reset', data: 'repainted-page-data' }] + ]) + expect( + mainWindow.webContents.send.mock.calls.filter( + (call) => call[0] === 'pty:modelRestoreNeeded' + ) + ).toHaveLength(0) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('commits interactive bypass removal and producer flow after a synchronous send failure', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writePty = getPtyWriteListener() + mainWindow.webContents.send.mockClear() + let failed = false + mainWindow.webContents.send.mockImplementation((channel: string) => { + if (channel === 'pty:data' && !failed) { + failed = true + throw new Error('synthetic send failure') + } + }) + + mockProc.emitData('older-') + writePty(mainWindowIpcEvent, { id: spawn.id, data: 'x' }) + mockProc.emitData('redraw') + + expect(getPtyDataSendCalls()).toEqual([['pty:data', { id: spawn.id, data: 'older-redraw' }]]) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + pendingChars: 0, + rendererInFlightChars: 0, + flushScheduled: false + }) + expect(vi.getTimerCount()).toBe(0) + + mockProc.emitData('recovery') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawn.id, + reason: 'delivery-heal' + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('cleans up and emits exit once when the final data send fails synchronously', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + const pending = 'x'.repeat(320 * 1024) + provider.emitData('send-fail-exit', pending) + expect(provider.pauseProducer).toHaveBeenCalledWith('send-fail-exit') + mainWindow.webContents.send.mockClear() + mainWindow.webContents.send.mockImplementation((channel: string) => { + if (channel === 'pty:data') { + throw new Error('synthetic send failure') + } + }) + + provider.emitExit('send-fail-exit', 7) + + expect(getPtyDataSendCalls()).toEqual([['pty:data', { id: 'send-fail-exit', data: pending }]]) + expect( + mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') + ).toEqual([['pty:exit', { id: 'send-fail-exit', code: 7 }]]) + expect( + mainWindow.webContents.send.mock.calls.filter( + (call) => call[0] === 'pty:modelRestoreNeeded' + ) + ).toHaveLength(0) + expect(provider.resumeProducer).toHaveBeenCalledWith('send-fail-exit') + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + pendingChars: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + flushScheduled: false + }) + expect(vi.getTimerCount()).toBe(0) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('delivers a pending-cap sentinel before exit and clears its pending timer', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + provider.emitData('flood-pty', 'x'.repeat(3 * 1024 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 0, + flushScheduled: true + }) + mainWindow.webContents.send.mockClear() + + provider.emitExit('flood-pty', 0) + + expect(mainWindow.webContents.send.mock.calls).toEqual([ + ['pty:data', { id: 'flood-pty', data: '', droppedOutput: true }], + ['pty:exit', { id: 'flood-pty', code: 0 }] + ]) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + flushScheduled: false + }) + expect(vi.getTimerCount()).toBe(0) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + const DELIVERY_RESYNC_UNANSWERED_WARNING = '[pty] delivery resync probe unanswered — renderer IPC unresponsive' @@ -10768,6 +11367,37 @@ describe('registerPtyHandlers', () => { } }) + it('keeps zero, duplicate, and stale ACKs to one legacy no-write timer', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + expect(getPtyDataSendCalls()).toHaveLength(32) + expect(vi.getTimerCount()).toBe(0) + + ackData(null, { id: spawnResult.id, processedChars: 0 }) + expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(true) + expect(vi.getTimerCount()).toBe(1) + ackData(null, { id: spawnResult.id, processedChars: 0 }) + ackData(null, { id: spawnResult.id, processedChars: -1 }) + expect(vi.getTimerCount()).toBe(1) + + vi.runOnlyPendingTimers() + expect(getPtyDataSendCalls()).toHaveLength(32) + expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(false) + expect(vi.getTimerCount()).toBe(0) + + ackData(null, { id: spawnResult.id, processedChars: 16 * 1024 }) + vi.runOnlyPendingTimers() + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + vi.useRealTimers() + } + }) + it('tolerates mixed legacy delta and cumulative ACK payloads', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -11104,6 +11734,54 @@ describe('registerPtyHandlers', () => { } }) + it('reactivates globally blocked work immediately after a delivery writeoff', () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + const bulkIds = Array.from({ length: 16 }, (_, index) => `writeoff-bulk-${index}`) + mainWindow.webContents.send.mockClear() + for (const id of bulkIds) { + provider.emitData(id, 'x'.repeat(600 * 1024)) + } + vi.advanceTimersByTime(2) + for (let index = 0; index < 400; index++) { + vi.advanceTimersByTime(1) + } + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 8 * 1024 * 1024, + flushScheduled: false + }) + + provider.emitData('writeoff-held', 'held') + vi.advanceTimersByTime(2) + expect( + getPtyDataSendCalls().some( + (call) => (call[1] as { id?: string } | undefined)?.id === 'writeoff-held' + ) + ).toBe(false) + expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(false) + + reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {}, + heal: true, + rendererPtyDataListenerCount: 1 + }) + expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(true) + vi.advanceTimersByTime(0) + + expect(getPtyDataSendCalls().at(-1)).toEqual([ + 'pty:data', + { id: 'writeoff-held', data: 'held' } + ]) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + it('never writes off bytes the renderer received but has not parsed yet', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -11370,6 +12048,242 @@ describe('registerPtyHandlers', () => { } }) + it('reactivates every globally blocked PTY when an exit releases renderer credit', async () => { + vi.useFakeTimers() + const procs = Array.from({ length: 17 }, () => createMockProc()) + for (const proc of procs) { + spawnMock.mockReturnValueOnce(proc.proc) + } + + try { + registerPtyHandlers(mainWindow as never) + const spawns: { id: string }[] = [] + for (const _proc of procs) { + spawns.push( + (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + ) + } + mainWindow.webContents.send.mockClear() + for (const proc of procs) { + proc.emitData('x'.repeat(600 * 1024)) + } + vi.advanceTimersByTime(2) + for (let index = 0; index < 400; index++) { + vi.advanceTimersByTime(1) + } + expect(getPtyDataSendCalls()).toHaveLength(512) + expect(vi.getTimerCount()).toBe(0) + + mainWindow.webContents.send.mockClear() + procs[0]!.emitExit(0) + const exitIndex = mainWindow.webContents.send.mock.calls.findIndex( + (call) => call[0] === 'pty:exit' + ) + expect(exitIndex).toBeGreaterThanOrEqual(0) + vi.advanceTimersByTime(0) + + expect( + mainWindow.webContents.send.mock.calls + .slice(exitIndex + 1) + .some( + (call) => + call[0] === 'pty:data' && + (call[1] as { id?: string } | undefined)?.id !== spawns[0]!.id + ) + ).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('wakes blocked PTYs when a zero-write hidden drop reentrantly releases exit credit', async () => { + vi.useFakeTimers() + const bulkProcs = Array.from({ length: 16 }, () => createMockProc()) + const hiddenProc = createMockProc() + const heldProc = createMockProc() + for (const proc of [...bulkProcs, hiddenProc, heldProc]) { + spawnMock.mockReturnValueOnce(proc.proc) + } + + try { + registerPtyHandlers(mainWindow as never) + const bulkSpawns: { id: string }[] = [] + for (const _proc of bulkProcs) { + bulkSpawns.push( + (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + ) + } + const hiddenSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + }) + + for (const proc of bulkProcs) { + proc.emitData('x'.repeat(600 * 1024)) + } + vi.advanceTimersByTime(2) + for (let index = 0; index < 400; index++) { + vi.advanceTimersByTime(1) + } + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 8 * 1024 * 1024, + flushScheduled: false + }) + + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + setHidden(null, { id: hiddenSpawn.id, hidden: true }) + setInterest(null, { id: hiddenSpawn.id, interested: true }) + hiddenProc.emitData('drop-without-write') + heldProc.emitData('held') + setInterest(null, { id: hiddenSpawn.id, interested: false }) + mainWindow.webContents.send.mockClear() + let reentered = false + mainWindow.webContents.send.mockImplementation( + (channel: string, payload: { id?: string }) => { + if (channel === 'pty:modelRestoreNeeded' && payload.id === hiddenSpawn.id && !reentered) { + reentered = true + bulkProcs[0]!.emitExit(0) + } + } + ) + + vi.advanceTimersByTime(2) + + const exitIndex = mainWindow.webContents.send.mock.calls.findIndex( + (call) => call[0] === 'pty:exit' + ) + expect(exitIndex).toBeGreaterThanOrEqual(0) + expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(true) + vi.advanceTimersByTime(1) + expect( + mainWindow.webContents.send.mock.calls + .slice(exitIndex + 1) + .some( + (call) => + call[0] === 'pty:data' && + (call[1] as { id?: string } | undefined)?.id !== bulkSpawns[0]!.id + ) + ).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('does not reactivate globally blocked PTYs when exit releases no prior credit', async () => { + vi.useFakeTimers() + const bulkProcs = Array.from({ length: 16 }, () => createMockProc()) + const finalProc = createMockProc() + const heldProc = createMockProc() + for (const proc of [...bulkProcs, finalProc, heldProc]) { + spawnMock.mockReturnValueOnce(proc.proc) + } + + try { + registerPtyHandlers(mainWindow as never) + for (const _proc of bulkProcs) { + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + }) + } + const finalSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string; incarnationId: string } + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + }) + + for (const proc of bulkProcs) { + proc.emitData('x'.repeat(600 * 1024)) + } + vi.advanceTimersByTime(2) + for (let index = 0; index < 400; index++) { + vi.advanceTimersByTime(1) + } + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 8 * 1024 * 1024, + flushScheduled: false + }) + + finalProc.emitData('final-tail') + heldProc.emitData('held') + vi.advanceTimersByTime(2) + expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(false) + const timerCountBeforeExit = vi.getTimerCount() + mainWindow.webContents.send.mockClear() + + finalProc.emitExit(0) + + expect(mainWindow.webContents.send.mock.calls).toEqual([ + ['pty:data', { id: finalSpawn.id, data: 'final-tail' }], + ['pty:exit', { id: finalSpawn.id, code: 0, incarnationId: finalSpawn.incarnationId }] + ]) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 8 * 1024 * 1024, + flushScheduled: false + }) + expect(vi.getTimerCount()).toBe(timerCountBeforeExit) + } finally { + vi.useRealTimers() + } + }) + + it('does not schedule a teardown-only flush for the last active blocked PTY', async () => { + vi.useFakeTimers() + const proc = createMockProc() + spawnMock.mockReturnValue(proc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + getPtySetActiveRendererPtyListener()(null, { id: spawn.id, active: true }) + proc.emitData('x'.repeat(1200 * 1024)) + vi.advanceTimersByTime(2) + for (let index = 0; index < 80; index++) { + vi.advanceTimersByTime(1) + } + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + flushScheduled: false + }) + expect(vi.getTimerCount()).toBe(0) + + proc.emitExit(0) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + flushScheduled: false + }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('prioritizes active PTY pending output during renderer backpressure', async () => { vi.useFakeTimers() const procs = Array.from({ length: 18 }, () => createMockProc()) @@ -11695,6 +12609,58 @@ describe('registerPtyHandlers', () => { } }) + it('drops queued hidden data when interest ends before dispatcher readiness', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + const setActive = getPtySetActiveRendererPtyListener() + getMainWindowWebContentsListener('did-start-loading')() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setInterest(null, { id: spawnResult.id, interested: true }) + mockProc.emitData('boot-window sidecar bytes') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + rendererPtyDispatcherReady: false, + ackGatedFlushSkipCount: 0 + }) + + const timerCountBeforeNoops = vi.getTimerCount() + setHidden(null, { id: spawnResult.id, hidden: true }) + setInterest(null, { id: spawnResult.id, interested: true }) + setActive(null, { id: spawnResult.id, active: false }) + expect(vi.getTimerCount()).toBe(timerCountBeforeNoops) + + setInterest(null, { id: spawnResult.id, interested: false }) + vi.advanceTimersByTime(0) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + rendererPtyDispatcherReady: false, + ackGatedFlushSkipCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + it.each([ ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] @@ -11727,6 +12693,53 @@ describe('registerPtyHandlers', () => { } }) + it.each(['terminalHiddenDeliveryGate', 'terminalMainSideEffectAuthority'] as const)( + 'reevaluates blocked hidden data when the live %s setting enables the derived gate', + async (settingName) => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + const settings = { + terminalHiddenDeliveryGate: true, + terminalMainSideEffectAuthority: true + } + settings[settingName] = false + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + getMainWindowWebContentsListener('did-start-loading')() + getPtySetHiddenRendererPtyListener()(null, { id: spawnResult.id, hidden: true }) + mainWindow.webContents.send.mockClear() + mockProc.emitData('blocked while gate disabled') + vi.advanceTimersByTime(2) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + rendererPtyDispatcherReady: false + }) + + settings[settingName] = true + getPtyAckDataListener()(null, { id: spawnResult.id, processedChars: 0 }) + vi.advanceTimersByTime(0) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 0, + rendererPtyDispatcherReady: false + }) + } finally { + vi.useRealTimers() + } + } + ) + it('drops queued pending data when a PTY is marked hidden', async () => { vi.useFakeTimers() const mockProc = createMockProc() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index a78e91ac692..688df301a70 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -57,12 +57,18 @@ import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind' import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' import { PtyProcessListAdmission, visitPtyProcessListingsInBatches } from '../providers/pty-process-list-admission' +import type { PtyDataEvent } from '../providers/pty-provider-events' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { SSH_SESSION_EXPIRED_ERROR, @@ -151,6 +157,25 @@ import { shouldDropHiddenRendererPtyData, unmarkHiddenRendererPty } from './pty-hidden-delivery-gate' +import { PtyPendingDataDrainQueue, type PendingPtyData } from './pty-pending-data-drain-queue' +import type { + PtyPendingDataDrainRound, + PtyPendingDataDrainSelection as DrainPtyPendingDataDrainSelection +} from './pty-pending-data-drain-queue' +import { + appendPtyDeliveryCredit, + PtyRendererDeliveryCreditLedger, + settlePtyDeliveryCredit, + takePtyDeliveryCredit, + type PtyDeliveryCreditSpan +} from './pty-renderer-delivery-credit' +import { + PendingPtyDataMap, + PtyRendererDeliveryAccountingMap, + PtyRendererDeliveryIdSet, + settleRejectedPtyRendererDelivery +} from './pty-renderer-delivery-retention' +import { installExternalPtyRendererDeliveryRouter } from './pty-renderer-delivery-router' import { clearNativeWindowsConptyPty, isNativeWindowsLocalPtySpawn, @@ -174,6 +199,9 @@ import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import { isPtyIncarnationId } from '../../shared/pty-incarnation' +const UnboundedPtyPendingDataDrainQueue = PtyPendingDataDrainQueue +type DrainPendingPtyData = PendingPtyData + // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId (null = local provider). @@ -182,6 +210,7 @@ type FreshLocalFallbackProvider = IPtyProvider & { routesFreshSpawnsToLocalProvider?: true } const sshProviders = new Map() +export const MAX_REGISTERED_SSH_PTY_PROVIDERS = 256 type RegisteredPtyProvider = { provider: IPtyProvider @@ -214,6 +243,8 @@ const interactiveOutputCharsByPty = new Map() const activeRendererPtys = new Set() const visibleRendererPtys = new Set() const rendererVisibilityKnownPtys = new Set() +let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {} +let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {} const pendingHiddenRendererResizeOutputPtys = new Set() const deliveredHiddenRendererResizeOutputPtys = new Set() const KEEP_HISTORY_STOP_SETTLE_MS = 1_000 @@ -281,39 +312,35 @@ async function reconcileAgentSessionOwnerListings(): Promise { return await agentSessionOwnerReconciliation } const reconciliation = (async () => { - const providers: { provider: IPtyProvider; connectionId: string | null }[] = [ - { provider: localProvider, connectionId: null }, - ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) - ] - const listings = await Promise.all( - providers.map(async ({ provider, connectionId }) => ({ - connectionId, - sessions: await provider.listProcesses() - })) - ) + const admission = new PtyProcessListAdmission('execution_owner_unavailable') const advertisedOwners: AgentSessionOwnerBinding[] = [] const advertisedOwnerSessions: { id: string connectionId: string | null incarnationId: string }[] = [] - for (const { connectionId, sessions } of listings) { - for (const session of sessions) { - const incarnationId = session.incarnationId - let hasAdvertisedOwner = false - for (const owner of session.agentSessionOwners ?? []) { - if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { - // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. - throw new Error('agent_session_ownership_unknown') + await visitPtyProcessListingsInBatches( + registeredPtyProviders(), + ({ provider }) => provider.listProcesses(), + ({ connectionId }, sessions) => { + for (const rawSession of sessions) { + const session = admission.admit(rawSession) + const incarnationId = session.incarnationId + let hasAdvertisedOwner = false + for (const owner of session.agentSessionOwners ?? []) { + if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { + // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. + throw new Error('agent_session_ownership_unknown') + } + advertisedOwners.push(owner) + hasAdvertisedOwner = true + } + if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { + advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) } - advertisedOwners.push(owner) - hasAdvertisedOwner = true - } - if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { - advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) } } - } + ) agentSessionOwners.reconcileAuthoritative(advertisedOwners, { // Why: an unregistered relay can still own a live PTY during reconnect; // only providers that serialize claims may make listing absence authoritative. @@ -1073,6 +1100,18 @@ export function buildPtyHostEnv( if (guestEndpoint) { baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint } + // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. + const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro) + if (opencodeOverlayDir) { + baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir + baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } else { + // Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression). + delete baseEnv.OPENCODE_CONFIG_DIR + delete baseEnv.ORCA_OPENCODE_CONFIG_DIR + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } } } @@ -1210,6 +1249,9 @@ function beginPtySpawnForWorktree( /** Register an SSH PTY provider for a connection. */ export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { + if (!sshProviders.has(connectionId) && sshProviders.size >= MAX_REGISTERED_SSH_PTY_PROVIDERS) { + throw new Error('ssh_pty_provider_capacity') + } sshProviders.set(connectionId, provider) } @@ -1283,13 +1325,20 @@ export function clearProviderPtyState( ptyIncarnationById.delete(id) lastInputAtByPty.delete(id) interactiveOutputCharsByPty.delete(id) - activeRendererPtys.delete(id) + const activeChanged = activeRendererPtys.delete(id) visibleRendererPtys.delete(id) rendererVisibilityKnownPtys.delete(id) pendingHiddenRendererResizeOutputPtys.delete(id) deliveredHiddenRendererResizeOutputPtys.delete(id) // Why: every teardown path funnels through here — hidden/interest gate bits must not outlive the PTY or a reused map entry could silently gate a new one. + const deliveryPolicyChanged = isHiddenRendererPty(id) clearHiddenRendererPtyDeliveryState(id) + if (activeChanged) { + invalidatePendingPtyDrainPriority(id, false) + } + if (deliveryPolicyChanged) { + invalidatePendingPtyDrainPolicy(id, false) + } clearBackgroundedDeliverySyncForPty(id) providerSnapshotRequiredPtys.delete(id) // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. @@ -1486,10 +1535,14 @@ function markRendererPtysHiddenForRendererLifecycleReset(): void { // A reload/crash in the breadcrumb history is load-bearing context for any freeze report. mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') // Why: renderer-owned hints die with the page; clear visibility so surviving daemon/SSH PTYs fail closed until the new renderer reports. + const activePriorityChanged = activeRendererPtys.size > 0 activeRendererPtys.clear() visibleRendererPtys.clear() // Why: the dead page never ACKs its in-flight bytes, so leaked accounting would delivery-gate surviving PTYs forever after a reload/crash. resetRendererDeliveryAccountingForLifecycleReset() + if (activePriorityChanged) { + invalidatePendingPtyDrainPriority() + } } function clearRendererLifecycleResetHandlers(): void { @@ -1578,6 +1631,8 @@ export function registerPtyHandlers( // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. clearRendererDispatcherReadyWatchdog() resetRendererDeliveryAccountingForLifecycleReset = () => {} + invalidatePendingPtyDrainPriority = () => {} + invalidatePendingPtyDrainPolicy = () => {} registerRendererLifecycleResetHandlers(mainWindow.webContents) const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { @@ -1702,15 +1757,11 @@ export function registerPtyHandlers( }) } - // Why: batching PTY data into short flush windows cuts IPC round-trips from hundreds/sec to ~120/sec; keystroke echo/redraws bypass it below. - type PendingPtyData = { - data: string - startSeq?: number - rawLength?: number - transformed?: true - containsBackgroundOutput?: boolean - // Why droppedOutput (not main's droppedBacklog trim): this branch's drop-to-sentinel + snapshot-restore supersedes #7630's 2MB-tail trim; both would race two cap policies over one buffer. - droppedOutput?: true + type PendingPtyData = DrainPendingPtyData & { + upstreamCreditSpans?: PtyDeliveryCreditSpan[] + } + type PendingPtyDataDrainSelection = Omit & { + readonly pending: PendingPtyData } type PtyDataPayload = { @@ -1723,10 +1774,130 @@ export function registerPtyHandlers( droppedOutput?: boolean } - const pendingData = new Map() - let pendingDataTotalChars = 0 + class PtyPendingDataDrainQueue extends UnboundedPtyPendingDataDrainQueue { + private readonly retained: PendingPtyDataMap + + constructor( + classify: ConstructorParameters[0], + readPolicyToken: ConstructorParameters[1] + ) { + super(classify, readPolicyToken) + this.retained = new PendingPtyDataMap( + (pending) => ({ + chars: pending.data.length, + creditSpans: pending.upstreamCreditSpans?.length ?? 0 + }), + rejectPendingPtyDataForRetentionPressure + ) + } + + get(id: string): PendingPtyData | undefined { + return this.retained.get(id) + } + + keys(): IterableIterator { + return this.retained.keys() + } + + values(): IterableIterator { + return this.retained.values() + } + + entries(): IterableIterator<[string, PendingPtyData]> { + return this.retained.entries() + } + + [Symbol.iterator](): IterableIterator<[string, PendingPtyData]> { + return this.entries() + } + + set(id: string, pending: PendingPtyData): void { + super.set(id, pending) + if (!this.retained.admit(id, pending)) { + super.delete(id) + } + } + + delete(id: string): PendingPtyData | undefined { + const pending = super.delete(id) as PendingPtyData | undefined + this.retained.delete(id) + return pending + } + + clear(): void { + super.clear() + this.retained.clear() + } + + takeNext(round: PtyPendingDataDrainRound): PendingPtyDataDrainSelection | null { + return super.takeNext(round) as PendingPtyDataDrainSelection | null + } + + block(selection: PendingPtyDataDrainSelection): void { + super.block(selection) + } + + remove(selection: PendingPtyDataDrainSelection): void { + this.retained.delete(selection.id) + super.remove(selection) + } + + replaceWithRemainder( + selection: PendingPtyDataDrainSelection, + pending: PendingPtyData + ): void { + if (!this.retained.admit(selection.id, pending)) { + super.remove(selection) + return + } + super.replaceWithRemainder(selection, pending) + } + } + + // Why: bounded batch windows amortize renderer IPC; keystroke echo/redraws bypass them below. + const pendingData = new PtyPendingDataDrainQueue( + (id) => { + const runnableLane = activeRendererPtys.has(id) ? 'active' : 'background' + // Why first: hidden bytes are dropped from main's pending queue even when renderer credit is exhausted. + if (shouldDropHiddenRendererPtyData(id, getSettings?.())) { + return runnableLane + } + if ( + !rendererPtyDispatcherReady || + !canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) }) + ) { + return 'blocked' + } + return runnableLane + }, + () => isHiddenPtyDeliveryGateEnabled(getSettings?.()) + ) + // Why: resuming a paused producer during exit can synchronously emit; those bytes must not queue behind pty:exit. + const rendererExitingPtyIds = new Set() + const rendererDeliveryRestoreNeededPtys = new Set() + const rendererDeliveryRestoreNeededPtyRetention = new PtyRendererDeliveryIdSet() + + function transitionHiddenRendererPtyDeliveryState(id: string, hidden: boolean) { + const settings = getSettings?.() + const wasDroppable = shouldDropHiddenRendererPtyData(id, settings) + let droppedWhileHidden = false + if (hidden) { + markHiddenRendererPty(id) + } else { + droppedWhileHidden = unmarkHiddenRendererPty(id).droppedWhileHidden + } + const droppable = shouldDropHiddenRendererPtyData(id, settings) + return { droppable, droppedWhileHidden, policyChanged: wasDroppable !== droppable } + } + + function transitionSpawnHiddenRendererPtyDeliveryState(id: string, hidden: boolean): void { + const transition = transitionHiddenRendererPtyDeliveryState(id, hidden) + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(id) + } + } // Why: one restore marker per overflow episode — cleared on full drain so a later overflow re-marks exactly once. - const pendingOverflowMarkedPtys = new Set() + const pendingOverflowMarkedPtys = new PtyRendererDeliveryIdSet() // Why: TCP-style cumulative accounting — monotonic sent/acked totals self-heal on any later ACK, where relative in-flight counters would make each lost ACK a permanent debt. type RendererPtyDeliveryAccounting = { sentChars: number @@ -1734,9 +1905,13 @@ export function registerPtyHandlers( lastSendAtMs: number lastAckAtMs: number | null } - const rendererDeliveryAccountingByPty = new Map() + const rendererDeliveryAccountingByPty = + new PtyRendererDeliveryAccountingMap() + const rendererDeliveryCredit = new PtyRendererDeliveryCreditLedger() const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null + let pendingDataFlushActive = false + let pendingDataCreditReleasedDuringFlush = false let rendererInFlightTotalChars = 0 let pendingDroppedChars = 0 let deliveryResyncRequestSerial = 0 @@ -1838,7 +2013,7 @@ export function registerPtyHandlers( // Why touched PTY only: pressure peaks are monotonic between explicit resets. function recordPtyRendererDeliveryPressure(id: string): void { - peakPendingChars = Math.max(peakPendingChars, pendingDataTotalChars) + peakPendingChars = Math.max(peakPendingChars, pendingData.totalPendingChars) peakMaxPendingCharsByPty = Math.max( peakMaxPendingCharsByPty, pendingData.get(id)?.data.length ?? 0 @@ -1851,24 +2026,16 @@ export function registerPtyHandlers( } function setPendingPtyData(id: string, pending: PendingPtyData): void { - const previousChars = pendingData.get(id)?.data.length ?? 0 pendingData.set(id, pending) - pendingDataTotalChars += pending.data.length - previousChars recordPtyRendererDeliveryPressure(id) } - function deletePendingPtyData(id: string): boolean { - const previousChars = pendingData.get(id)?.data.length - if (previousChars === undefined) { - return false - } - pendingDataTotalChars -= previousChars - return pendingData.delete(id) + function deletePendingPtyData(id: string): void { + pendingData.delete(id) } function clearPendingPtyData(): void { pendingData.clear() - pendingDataTotalChars = 0 } function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { @@ -2035,12 +2202,19 @@ export function registerPtyHandlers( rendererLifecycleResetCount += 1 // Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged. producerFlowControl.releaseAll() + settleAllPendingUpstreamCredit() + rendererDeliveryCredit.writeOffAll((id, chars) => { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) + }) clearDeliveryResyncProbe() deliveryResyncUnansweredWarnLogged = false rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 clearPendingPtyData() pendingOverflowMarkedPtys.clear() + pendingDataDropWarnedPtys.clear() + rendererDeliveryRestoreNeededPtys.clear() + rendererDeliveryRestoreNeededPtyRetention.clear() // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. rendererPtyDispatcherReady = false // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. @@ -2130,11 +2304,23 @@ export function registerPtyHandlers( accounting.ackedChars = nextAckedChars if (acknowledged > 0) { accounting.lastAckAtMs = Date.now() + rendererDeliveryCredit.acknowledge(id, acknowledged, (chars) => { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) + }) } rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) return acknowledged } + function schedulePendingDataAfterCreditReport(creditedAny: boolean): void { + if (creditedAny) { + pendingData.reactivateBlocked() + } + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } + } + function clearDeliveryResyncProbe(): void { deliveryResyncOutstandingRequestId = null if (deliveryResyncTimer) { @@ -2190,11 +2376,10 @@ export function registerPtyHandlers( if (acknowledged <= 0) { continue } - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - // Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore. const pending = pendingData.get(id) if (pending) { pendingDroppedChars += pending.data.length + settlePendingPtyDataDelivery(id, pending) deletePendingPtyData(id) pendingOverflowMarkedPtys.delete(id) updateProducerFlowControl(id) @@ -2223,23 +2408,78 @@ export function registerPtyHandlers( return writtenOff } - function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): void { + function sendPtyDataToRenderer( + id: string, + payload: PtyDataPayload, + upstreamCredit?: PtyDeliveryCreditSpan[] + ): boolean + function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): boolean { + const upstreamCredit = arguments[2] as PtyDeliveryCreditSpan[] | undefined const charCount = getPtyPayloadCharCount(payload) - const accounting = rendererDeliveryAccountingByPty.get(id) - if (accounting) { - accounting.sentChars += charCount - accounting.lastSendAtMs = Date.now() - } else { - rendererDeliveryAccountingByPty.set(id, { - sentChars: charCount, + let accounting = rendererDeliveryAccountingByPty.get(id) + const hadAccounting = accounting !== undefined + if (!accounting) { + accounting = { + sentChars: 0, ackedChars: 0, lastSendAtMs: Date.now(), lastAckAtMs: null - }) + } + if (!rendererDeliveryAccountingByPty.admit(id, accounting)) { + rejectSentPtyDataForRetentionPressure(id, payload, upstreamCredit) + return true + } } + accounting.sentChars += charCount + accounting.lastSendAtMs = Date.now() rendererInFlightTotalChars += charCount recordPtyRendererDeliveryPressure(id) - mainWindow.webContents.send('pty:data', payload) + try { + mainWindow.webContents.send('pty:data', payload) + } catch (error) { + const current = rendererDeliveryAccountingByPty.get(id) + if (current) { + const inFlightBeforeRollback = current.sentChars - current.ackedChars + current.sentChars = Math.max(0, current.sentChars - charCount) + current.ackedChars = Math.min(current.ackedChars, current.sentChars) + const inFlightAfterRollback = current.sentChars - current.ackedChars + rendererInFlightTotalChars = Math.max( + 0, + rendererInFlightTotalChars - (inFlightBeforeRollback - inFlightAfterRollback) + ) + if (!hadAccounting && current.sentChars === 0) { + rendererDeliveryAccountingByPty.delete(id) + } + } + settleRejectedPtyRendererDelivery(charCount, upstreamCredit, (chars) => + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) + ) + if (rendererDeliveryRestoreNeededPtyRetention.remember(id)) { + rendererDeliveryRestoreNeededPtys.add(id) + } + mainDeliveryBreadcrumbs.record('pty-data-send-failed', { + id: redactPtyIdForDiagnostics(id), + chars: charCount + }) + console.error('[pty] renderer data send failed; payload will not be retried', error) + return false + } + rendererDeliveryCredit.recordSent(id, charCount, upstreamCredit, (chars) => { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) + }) + if (rendererDeliveryRestoreNeededPtys.has(id)) { + try { + sendModelRestoreNeededMarker(id, 'delivery-heal', runtime?.getPtyOutputSequence(id)) + rendererDeliveryRestoreNeededPtys.delete(id) + rendererDeliveryRestoreNeededPtyRetention.delete(id) + } catch (error) { + console.error( + '[pty] renderer delivery-heal marker send failed; restore remains pending', + error + ) + } + } + return true } function rendererPtyIsKnownHidden(id: string): boolean { @@ -2285,21 +2525,65 @@ export function registerPtyHandlers( }) } - function getPendingPtyFlushEntries(): [string, PendingPtyData][] { - const entries = Array.from(pendingData.entries()) - const active: [string, PendingPtyData][] = [] - const background: [string, PendingPtyData][] = [] - for (const entry of entries) { - if (activeRendererPtys.has(entry[0])) { - active.push(entry) - } else { - background.push(entry) - } + const pendingDataDropWarnedPtys = new PtyRendererDeliveryIdSet() + + function settlePendingPtyDataDelivery(id: string, pending: PendingPtyData): void { + if (pending.droppedOutput === true) { + settlePtyDeliveryCredit(pending.upstreamCreditSpans) + return } - return [...active, ...background] + settleRejectedPtyRendererDelivery( + pending.rawLength ?? pending.data.length, + pending.upstreamCreditSpans, + (chars) => tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) + ) } - const pendingDataDropWarnedPtys = new Set() + function settleAllPendingUpstreamCredit(): void { + for (const [id, pending] of pendingData.entries()) { + settlePendingPtyDataDelivery(id, pending) + } + } + + function markPtyRendererDeliveryDropped(id: string, droppedChars: number, detail: string): void { + pendingDroppedChars += droppedChars + if (pendingOverflowMarkedPtys.remember(id)) { + sendModelRestoreNeededMarker(id, 'pending-cap', runtime?.getPtyOutputSequence(id)) + } + if (!pendingDataDropWarnedPtys.remember(id)) { + return + } + console.error(`[pty] dropped ${droppedChars} buffered chars: ${detail}`) + recordCrashBreadcrumb('terminal_pending_output_dropped', { + droppedChars, + capChars: pendingDataCapChars() + }) + } + + function rejectPendingPtyDataForRetentionPressure(id: string, pending: PendingPtyData): void { + settlePendingPtyDataDelivery(id, pending) + producerFlowControl.release(id) + markPtyRendererDeliveryDropped( + id, + pending.data.length, + 'aggregate renderer pending-output retention limit exceeded; pane will restore from the main-owned snapshot' + ) + } + + function rejectSentPtyDataForRetentionPressure( + id: string, + payload: PtyDataPayload, + upstreamCredit: PtyDeliveryCreditSpan[] | undefined + ): void { + settleRejectedPtyRendererDelivery(getPtyPayloadCharCount(payload), upstreamCredit, (chars) => + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) + ) + markPtyRendererDeliveryDropped( + id, + payload.data.length, + 'aggregate renderer delivery-accounting limit exceeded; pane will restore from the main-owned snapshot' + ) + } // Why capped: keeps O(1) memory per PTY; salvaged query bytes are tiny, so past the cap a pathological stream can degrade to the plain sentinel. const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 @@ -2318,8 +2602,7 @@ export function registerPtyHandlers( if (pending.droppedOutput === true || pending.data.length <= capChars) { return pending } - if (!pendingDataDropWarnedPtys.has(id)) { - pendingDataDropWarnedPtys.add(id) + if (pendingDataDropWarnedPtys.remember(id)) { console.error( `[pty] dropped ${pending.data.length} buffered chars for ${id}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` ) @@ -2329,12 +2612,11 @@ export function registerPtyHandlers( capChars }) } - // Why the marker: the snapshot can recover the dropped middle; emit it once per overflow episode so a fresh or reloaded view latches restore too. - if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) { - pendingOverflowMarkedPtys.add(id) - sendModelRestoreNeededMarker(id, 'pending-cap', runtime?.getPtyOutputSequence(id)) + if (isHiddenPtyDeliveryGateEnabled(getSettings?.())) { + pendingOverflowMarkedPtys.remember(id) } pendingDroppedChars += pending.data.length + settlePendingPtyDataDelivery(id, pending) // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). return { data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), @@ -2350,10 +2632,16 @@ export function registerPtyHandlers( preservesSeq: boolean, containsBackgroundOutput: boolean, rawLength = data.length, - transformed = false + transformed = false, + upstreamCredit?: PtyDataEvent['upstreamCredit'] ): PendingPtyData { // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. if (existing?.droppedOutput === true) { + settleRejectedPtyRendererDelivery( + rawLength, + appendPtyDeliveryCredit(undefined, upstreamCredit), + (chars) => tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) + ) if (existing.data.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) { return existing } @@ -2368,16 +2656,24 @@ export function registerPtyHandlers( ...(typeof startSeq === 'number' ? { startSeq } : {}), ...(rawLength !== data.length ? { rawLength } : {}), ...(transformed ? { transformed: true } : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}), + ...(upstreamCredit + ? { upstreamCreditSpans: appendPtyDeliveryCredit(undefined, upstreamCredit) } + : {}) }) } const existingRawLength = existing.rawLength ?? existing.data.length + const upstreamCreditSpans = appendPtyDeliveryCredit( + existing.upstreamCreditSpans, + upstreamCredit + ) const next: PendingPtyData = { data: existing.data + data, ...(!preservesSeq || existing.transformed || transformed ? { rawLength: existingRawLength + rawLength, transformed: true as const } : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}), + ...(upstreamCreditSpans ? { upstreamCreditSpans } : {}) } if (typeof existing.startSeq === 'number') { next.startSeq = existing.startSeq @@ -2392,6 +2688,16 @@ export function registerPtyHandlers( flushTimer = setTimeout(flushPendingData, delayMs) } + function invalidatePendingPtyDrainClassification(id?: string, schedule = true): void { + const invalidated = + typeof id === 'string' ? pendingData.invalidate(id) : pendingData.invalidateAll() + if (invalidated && schedule && !flushTimer) { + schedulePendingDataFlush(0) + } + } + invalidatePendingPtyDrainPriority = invalidatePendingPtyDrainClassification + invalidatePendingPtyDrainPolicy = invalidatePendingPtyDrainClassification + function clearDispatcherReadyWatchdog(): void { if (dispatcherReadyWatchdogTimer) { clearTimeout(dispatcherReadyWatchdogTimer) @@ -2412,6 +2718,7 @@ export function registerPtyHandlers( } rendererPtyDispatcherReady = true rendererDispatcherReadyForcedCount += 1 + pendingData.reactivateBlocked() schedulePendingDataFlush(0) }, PTY_DISPATCHER_READY_WATCHDOG_MS) dispatcherReadyWatchdogTimer.unref?.() @@ -2422,83 +2729,124 @@ export function registerPtyHandlers( if (mainWindow.isDestroyed()) { // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. producerFlowControl.releaseAll() + settleAllPendingUpstreamCredit() + rendererDeliveryCredit.writeOffAll((id, chars) => { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) + }) clearDeliveryResyncProbe() clearPendingPtyData() pendingOverflowMarkedPtys.clear() + pendingDataDropWarnedPtys.clear() rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 clearDispatcherReadyWatchdog() return } - // Why hold: the page's pty:data listener isn't registered yet; bytes accrue in pendingData (rebuilt losslessly) and the ready handshake reschedules this flush. - if (!rendererPtyDispatcherReady) { - return - } + // Ordinary boot-window data is blocked in the queue; hidden-droppable entries still retire before renderer readiness. const settings = getSettings?.() let writes = 0 - for (const [id, pending] of getPendingPtyFlushEntries()) { - if (writes >= PTY_BATCH_FLUSH_MAX_WRITES) { - break - } - // Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery. - if (shouldDropHiddenRendererPtyData(id, settings)) { - deletePendingPtyData(id) - pendingOverflowMarkedPtys.delete(id) - updateProducerFlowControl(id) - const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) - warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id)) + let sendFailed = false + const round = pendingData.beginRound() + let creditReleasedDuringFlush = false + pendingDataFlushActive = true + pendingDataCreditReleasedDuringFlush = false + try { + while (writes < PTY_BATCH_FLUSH_MAX_WRITES) { + const selection = pendingData.takeNext(round) + if (!selection) { + break + } + const { id, pending } = selection + // Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery. + if (shouldDropHiddenRendererPtyData(id, settings)) { + pendingData.remove(selection) + settlePendingPtyDataDelivery(id, pending) + pendingOverflowMarkedPtys.delete(id) + updateProducerFlowControl(id) + const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) + warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id)) + } + continue + } + if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { + pendingData.block(selection) + continue + } + if (pending.droppedOutput === true) { + pendingData.remove(selection) + updateProducerFlowControl(id) + // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). + if (!sendPtyDataToRenderer(id, { id, data: pending.data, droppedOutput: true })) { + sendFailed = true + break + } + writes++ + continue + } + const { data } = pending + const indivisible = pending.transformed === true + const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) + const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) + const chunkRawLength = indivisible ? (pending.rawLength ?? chunk.length) : chunk.length + const upstreamCredit = takePtyDeliveryCredit(pending.upstreamCreditSpans, chunkRawLength) + if (remaining) { + const nextPending: PendingPtyData = { data: remaining } + if (typeof pending.startSeq === 'number') { + nextPending.startSeq = pending.startSeq + chunk.length + } + if (pending.containsBackgroundOutput === true) { + nextPending.containsBackgroundOutput = true + } + if (pending.upstreamCreditSpans?.length) { + nextPending.upstreamCreditSpans = pending.upstreamCreditSpans + } + pendingData.replaceWithRemainder(selection, nextPending) + } else { + pendingData.remove(selection) + pendingOverflowMarkedPtys.delete(id) } - continue - } - if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { - continue - } - deletePendingPtyData(id) - if (pending.droppedOutput === true) { updateProducerFlowControl(id) - // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). - sendPtyDataToRenderer(id, { id, data: pending.data, droppedOutput: true }) + if ( + !sendPtyDataToRenderer( + id, + makePtyDataPayload( + id, + chunk, + pending.startSeq, + pending.containsBackgroundOutput, + pending.rawLength, + pending.transformed + ), + upstreamCredit + ) + ) { + sendFailed = true + break + } writes++ - continue } - const { data } = pending - const indivisible = pending.transformed === true - const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) - const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) - if (remaining) { - const nextPending: PendingPtyData = { data: remaining } - if (typeof pending.startSeq === 'number') { - nextPending.startSeq = pending.startSeq + chunk.length - } - if (pending.containsBackgroundOutput === true) { - nextPending.containsBackgroundOutput = true - } - setPendingPtyData(id, nextPending) - } else { - pendingOverflowMarkedPtys.delete(id) - } - updateProducerFlowControl(id) - sendPtyDataToRenderer( - id, - makePtyDataPayload( - id, - chunk, - pending.startSeq, - pending.containsBackgroundOutput, - pending.rawLength, - pending.transformed - ) - ) - writes++ + } finally { + pendingDataFlushActive = false + creditReleasedDuringFlush = pendingDataCreditReleasedDuringFlush + pendingDataCreditReleasedDuringFlush = false + pendingData.endRound(round) } - if (pendingData.size > 0 && writes === 0) { + if (rendererPtyDispatcherReady && pendingData.size > 0 && writes === 0 && !sendFailed) { ackGatedFlushSkipCount++ } - if (pendingData.size > 0 && writes > 0) { - // Why yield between slices: a background terminal can dump megabytes at once, and keystroke writes must not stall behind one flush. + if (sendFailed && pendingData.size > 0) { + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = null + } schedulePendingDataFlush(PTY_BATCH_DRAIN_CONTINUE_MS) + return + } + if (pendingData.size > 0 && (writes > 0 || creditReleasedDuringFlush)) { + // Why yield between slices: a background terminal can dump megabytes at once, and keystroke writes must not stall behind one flush. + schedulePendingDataFlush(writes > 0 ? PTY_BATCH_DRAIN_CONTINUE_MS : 0) } } @@ -2538,48 +2886,90 @@ export function registerPtyHandlers( function sendPtyExitToRenderer(payload: { id: string; code: number }): void { if (mainWindow.isDestroyed()) { + const pending = pendingData.get(payload.id) + if (pending) { + settlePendingPtyDataDelivery(payload.id, pending) + pendingData.delete(payload.id) + } + rendererDeliveryCredit.writeOff(payload.id, (chars) => { + tryGetProviderForPty(payload.id)?.acknowledgeDataEvent(payload.id, chars) + }) + rendererInFlightTotalChars = Math.max( + 0, + rendererInFlightTotalChars - getRendererInFlightCharsForPty(payload.id) + ) + rendererDeliveryAccountingByPty.delete(payload.id) + pendingDataDropWarnedPtys.delete(payload.id) return } - // Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost. - const remaining = pendingData.get(payload.id) - if (remaining) { - if (remaining.droppedOutput === true) { - // Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped. - sendPtyDataToRenderer(payload.id, { - id: payload.id, - data: remaining.data, - droppedOutput: true - }) - } else { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( + if (rendererExitingPtyIds.has(payload.id)) { + return + } + rendererExitingPtyIds.add(payload.id) + try { + const hadReleasableRendererCredit = getRendererInFlightCharsForPty(payload.id) > 0 + // Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost. + const remaining = pendingData.delete(payload.id) + clearFlushTimerIfIdle() + if (remaining) { + if (remaining.droppedOutput === true) { + // Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped. + sendPtyDataToRenderer(payload.id, { + id: payload.id, + data: remaining.data, + droppedOutput: true + }) + } else { + const upstreamCredit = takePtyDeliveryCredit( + remaining.upstreamCreditSpans, + remaining.rawLength ?? remaining.data.length + ) + settlePtyDeliveryCredit(remaining.upstreamCreditSpans) + sendPtyDataToRenderer( payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput, - remaining.rawLength, - remaining.transformed + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput, + remaining.rawLength, + remaining.transformed + ), + upstreamCredit ) - ) + } + } + // Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id. + producerFlowControl.release(payload.id) + pendingOverflowMarkedPtys.delete(payload.id) + pendingDataDropWarnedPtys.delete(payload.id) + rendererDeliveryRestoreNeededPtys.delete(payload.id) + rendererDeliveryRestoreNeededPtyRetention.delete(payload.id) + lastInputAtByPty.delete(payload.id) + interactiveOutputCharsByPty.delete(payload.id) + const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id) + rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit) + // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. + rendererDeliveryAccountingByPty.delete(payload.id) + rendererDeliveryCredit.writeOff(payload.id, (chars) => { + tryGetProviderForPty(payload.id)?.acknowledgeDataEvent(payload.id, chars) + }) + if (hadReleasableRendererCredit) { + if (pendingDataFlushActive) { + // Why: let the open round coalesce this wake into its one post-round continuation. + const reactivatedBlocked = pendingData.reactivateBlocked() + pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked + } else { + schedulePendingDataAfterCreditReport(true) + } } - deletePendingPtyData(payload.id) + mainWindow.webContents.send('pty:exit', { + ...payload, + ...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {}) + }) + } finally { + rendererExitingPtyIds.delete(payload.id) } - // Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id. - producerFlowControl.release(payload.id) - pendingOverflowMarkedPtys.delete(payload.id) - lastInputAtByPty.delete(payload.id) - interactiveOutputCharsByPty.delete(payload.id) - rendererInFlightTotalChars = Math.max( - 0, - rendererInFlightTotalChars - getRendererInFlightCharsForPty(payload.id) - ) - // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. - rendererDeliveryAccountingByPty.delete(payload.id) - mainWindow.webContents.send('pty:exit', { - ...payload, - ...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {}) - }) } function sendPtySpawnedToRenderer(id: string): void { @@ -2588,6 +2978,20 @@ export function registerPtyHandlers( } } + function sendPtyReplayToRenderer(payload: { id: string; data: string }): void { + const pending = pendingData.get(payload.id) + if (pending) { + settlePendingPtyDataDelivery(payload.id, pending) + pendingData.delete(payload.id) + pendingOverflowMarkedPtys.delete(payload.id) + updateProducerFlowControl(payload.id) + clearFlushTimerIfIdle() + } + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:replay', payload) + } + } + async function shutdownProviderAndDetectExit( provider: IPtyProvider, id: string, @@ -2611,6 +3015,148 @@ export function registerPtyHandlers( return providerExitObserved } + function routeProviderData(payload: PtyDataEvent, runtimeAlreadyIngested: boolean): void { + const rawLength = payload.sequenceChars ?? payload.data.length + const outputSeq = runtimeAlreadyIngested + ? runtime?.getPtyOutputSequence(payload.id) + : runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed) + const rendererData = payload.data + const preservesSeq = !payload.transformed && rawLength === payload.data.length + const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined + const settleCurrent = (): void => + settleRejectedPtyRendererDelivery( + rawLength, + appendPtyDeliveryCredit(undefined, payload.upstreamCredit), + (chars) => tryGetProviderForPty(payload.id)?.acknowledgeDataEvent(payload.id, chars) + ) + if (mainWindow.isDestroyed()) { + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = null + } + producerFlowControl.releaseAll() + settleCurrent() + settleAllPendingUpstreamCredit() + rendererDeliveryCredit.writeOffAll((id, chars) => { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) + }) + clearDeliveryResyncProbe() + clearPendingPtyData() + pendingOverflowMarkedPtys.clear() + pendingDataDropWarnedPtys.clear() + rendererDeliveryAccountingByPty.clear() + rendererInFlightTotalChars = 0 + clearDispatcherReadyWatchdog() + return + } + if (rendererExitingPtyIds.size > 0 && rendererExitingPtyIds.has(payload.id)) { + settleCurrent() + return + } + const settings = getSettings?.() + if (shouldDropHiddenRendererPtyData(payload.id, settings)) { + settleCurrent() + const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) + } + return + } + if (rendererData.length === 0 && !payload.transformed) { + settleCurrent() + return + } + const containsBackgroundOutput = + rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id) + if (containsBackgroundOutput) { + markHiddenRendererResizeOutputDelivered(payload.id) + } + const existing = pendingData.get(payload.id) + const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id) + const pending = appendPendingPtyData( + payload.id, + existing, + rendererData, + startSeq, + preservesSeq, + containsBackgroundOutput, + rawLength, + payload.transformed === true, + payload.upstreamCredit + ) + const shouldEmitPendingCapRestoreMarker = + pending.droppedOutput === true && + !overflowMarkedBeforeAppend && + pendingOverflowMarkedPtys.has(payload.id) + const nextData = pending.data + const isInteractiveOutput = shouldSendInteractiveOutputNow( + payload.id, + nextData, + performance.now() + ) + if (isInteractiveOutput && rendererPtyDispatcherReady) { + if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { + setPendingPtyData(payload.id, pending) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) + } + updateProducerFlowControl(payload.id) + requestDeliveryResyncForGatedPty() + return + } + deletePendingPtyData(payload.id) + clearFlushTimerIfIdle() + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) + } + pendingOverflowMarkedPtys.delete(payload.id) + const upstreamCredit = takePtyDeliveryCredit( + pending.upstreamCreditSpans, + pending.rawLength ?? nextData.length + ) + try { + sendPtyDataToRenderer( + payload.id, + { + id: payload.id, + data: nextData, + ...(typeof pending.startSeq === 'number' + ? { + seq: pending.startSeq + (pending.rawLength ?? nextData.length), + rawLength: pending.rawLength ?? nextData.length + } + : {}), + ...(pending.transformed ? { transformed: true } : {}), + ...(pending.containsBackgroundOutput === true ? { background: true } : {}), + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) + }, + upstreamCredit + ) + } finally { + updateProducerFlowControl(payload.id) + } + return + } + setPendingPtyData(payload.id, pending) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) + } + updateProducerFlowControl(payload.id) + if (!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })) { + requestDeliveryResyncForGatedPty() + } + if (!flushTimer) { + schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) + } + } + + installExternalPtyRendererDeliveryRouter({ + data: (payload) => routeProviderData(payload, false), + replay: sendPtyReplayToRenderer, + exit: sendPtyExitToRenderer + }) + // Why extracted: the "Restart daemon" flow rebinds against the fresh adapter after replaceDaemonProvider, sharing this code path with startup registration. const bindProviderListeners = (): void => { localDataUnsub?.() @@ -2644,106 +3190,7 @@ export function registerPtyHandlers( // Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty. const isLocalProvider = localProvider instanceof LocalPtyProvider - localDataUnsub = localProvider.onData((payload) => { - const rawLength = payload.sequenceChars ?? payload.data.length - const outputSeq = isLocalProvider - ? runtime?.getPtyOutputSequence(payload.id) - : runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed) - const rendererData = payload.data - const preservesSeq = !payload.transformed && rawLength === payload.data.length - const startSeq = - typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined - if (mainWindow.isDestroyed()) { - // Why clear the flush timer: macOS app re-activation otherwise leaks orphaned timers from the previous window's registration. - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null - } - producerFlowControl.releaseAll() - clearDeliveryResyncProbe() - clearPendingPtyData() - pendingOverflowMarkedPtys.clear() - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearDispatcherReadyWatchdog() - return - } - const settings = getSettings?.() - // Why drop before the interactive bypass: runtime already ingested the chunk, so gated PTYs skip both renderer paths and reveal restores from the snapshot. - if (shouldDropHiddenRendererPtyData(payload.id, settings)) { - const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) - warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) - } - return - } - if (rendererData.length === 0 && !payload.transformed) { - return - } - const containsBackgroundOutput = - rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id) - if (containsBackgroundOutput) { - markHiddenRendererResizeOutputDelivered(payload.id) - } - const existing = pendingData.get(payload.id) - const pending = appendPendingPtyData( - payload.id, - existing, - rendererData, - startSeq, - preservesSeq, - containsBackgroundOutput, - rawLength, - payload.transformed === true - ) - const nextData = pending.data - const isInteractiveOutput = shouldSendInteractiveOutputNow( - payload.id, - nextData, - performance.now() - ) - // Why gate the fast path on the handshake too: else boot-window keystroke echo is sent into a listener-less page and pins the gate. - if (isInteractiveOutput && rendererPtyDispatcherReady) { - // Why the reserve: keep input echo from being pinned behind unrelated bulk output; it's bounded and the per-PTY cap still prevents an active TUI runaway. - if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { - requestDeliveryResyncForGatedPty() - setPendingPtyData(payload.id, pending) - updateProducerFlowControl(payload.id) - return - } - deletePendingPtyData(payload.id) - updateProducerFlowControl(payload.id) - pendingOverflowMarkedPtys.delete(payload.id) - clearFlushTimerIfIdle() - // Why immediate: agent TUIs redraw small prompt regions per keystroke; the throughput batch timer would add visible input latency. - sendPtyDataToRenderer(payload.id, { - id: payload.id, - data: nextData, - ...(typeof pending.startSeq === 'number' - ? { - seq: pending.startSeq + (pending.rawLength ?? nextData.length), - rawLength: pending.rawLength ?? nextData.length - } - : {}), - ...(pending.transformed ? { transformed: true } : {}), - ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) - }) - return - } - setPendingPtyData(payload.id, pending) - updateProducerFlowControl(payload.id) - // Why probe on data arrival (not flush skips): new output for a fully gated PTY is the moment stuck delivery becomes observable. - if ( - !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) - ) { - requestDeliveryResyncForGatedPty() - } - if (!flushTimer) { - schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) - } - }) + localDataUnsub = localProvider.onData((payload) => routeProviderData(payload, isLocalProvider)) localExitUnsub = localProvider.onExit((payload) => { if (!isCurrentPtyExit(payload)) { return @@ -2864,15 +3311,17 @@ export function registerPtyHandlers( // Why: reload/crash orphans delivery-interest holds and hidden marks; reset so surviving PTYs aren't stuck force-fed or gated — each pane's first sync re-marks. clearRendererGateResetHandlers() - rendererGateResetLoadHandler = () => { + const resetRendererPtyDeliveryGateState = (): void => { + const gateDebug = getHiddenRendererPtyDeliveryDebug() resetRendererScopedHiddenPtyDeliveryState() + if (gateDebug.hiddenDeliveryGatedPtyCount > 0 || gateDebug.deliveryInterestPtyCount > 0) { + invalidatePendingPtyDrainPolicy() + } // Why: the daemon pacer must not keep throttling ptys whose hidden marks died with the renderer; the fresh renderer's sync re-marks the still-hidden ones. resyncBackgroundedDeliveriesAfterGateReset() } - rendererGateResetGoneHandler = () => { - resetRendererScopedHiddenPtyDeliveryState() - resyncBackgroundedDeliveriesAfterGateReset() - } + rendererGateResetLoadHandler = resetRendererPtyDeliveryGateState + rendererGateResetGoneHandler = resetRendererPtyDeliveryGateState rendererGateResetWebContents = mainWindow.webContents mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler) mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler) @@ -3845,11 +4294,18 @@ export function registerPtyHandlers( } }, listProcesses: async () => { - const providerSessions = await Promise.all([ - localProvider.listProcesses(), - ...Array.from(sshProviders.values(), (provider) => provider.listProcesses()) - ]) - return providerSessions.flat() + const processes: PtyProcessInfo[] = [] + const admission = new PtyProcessListAdmission() + await visitPtyProcessListingsInBatches( + registeredPtyProviders(), + ({ provider }) => provider.listProcesses(), + (_source, listing) => { + for (const process of listing) { + processes.push(admission.admit(process)) + } + } + ) + return processes }, serializeBuffer: (ptyId, opts) => { // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. @@ -4391,7 +4847,7 @@ export function registerPtyHandlers( ? effectiveSessionAppId : null if (preSpawnHiddenMarkId !== null) { - markHiddenRendererPty(preSpawnHiddenMarkId) + transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, true) } let result: PtySpawnResult let rejectedRegistrationCandidate: PtySpawnResult | null = null @@ -4455,7 +4911,7 @@ export function registerPtyHandlers( } // Why: a stale hidden mark on this session id would gate a later visible attach that reuses it. if (preSpawnHiddenMarkId !== null) { - unmarkHiddenRendererPty(preSpawnHiddenMarkId) + transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false) } const rawMessage = err instanceof Error ? err.message : String(err) if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) { @@ -4534,10 +4990,10 @@ export function registerPtyHandlers( } if (initiallyHidden) { // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). - markHiddenRendererPty(result.id) + transitionSpawnHiddenRendererPtyDeliveryState(result.id, true) if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) { // Defense: never strand a mark on an id the provider renamed. - unmarkHiddenRendererPty(preSpawnHiddenMarkId) + transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false) } // Why after ptyOwnership.set: provider lookup routes by ownership, and a hidden-spawned agent should be paceable from its first flood. syncPtyBackgroundedDelivery(result.id, 'spawn') @@ -5049,10 +5505,11 @@ export function registerPtyHandlers( const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 acknowledged = accounting ? applyCumulativeAck(args.id, accounting.ackedChars + delta) : 0 } - tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) + // Why preserve the zero callback: legacy providers observe every renderer ACK, while captured token owners still receive only their exact positive credit above. + if (acknowledged === 0) { + tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, 0) } + schedulePendingDataAfterCreditReport(acknowledged > 0) } ) @@ -5068,18 +5525,17 @@ export function registerPtyHandlers( clearDeliveryResyncProbe() deliveryResyncUnansweredWarnLogged = false // Why max-merge: the renderer's cumulative totals are authoritative for what it processed, draining exactly the in-flight debt from lost ACKs. + let creditedAny = false for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { continue } const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) if (acknowledged > 0) { - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + creditedAny = true } } - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) - } + schedulePendingDataAfterCreditReport(creditedAny) } ) @@ -5088,13 +5544,14 @@ export function registerPtyHandlers( 'pty:reportRendererDeliveryState', (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { // Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain. + let creditedAny = false for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { continue } const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) if (acknowledged > 0) { - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + creditedAny = true } } let writtenOff: PtyDeliveryWriteOff[] = [] @@ -5106,10 +5563,9 @@ export function registerPtyHandlers( Date.now() - lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS) ) { writtenOff = writeOffLostRendererDelivery(args) + creditedAny ||= writtenOff.length > 0 } - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) - } + schedulePendingDataAfterCreditReport(creditedAny) let inFlightPtyCount = 0 for (const accounting of rendererDeliveryAccountingByPty.values()) { if (accounting.sentChars - accounting.ackedChars > 0) { @@ -5139,6 +5595,7 @@ export function registerPtyHandlers( // Why: real handshake landed — cancel the self-heal watchdog so it can't later force-open the gate. clearDispatcherReadyWatchdog() rendererPtyDispatcherReady = true + pendingData.reactivateBlocked() schedulePendingDataFlush(0) }) @@ -5149,13 +5606,14 @@ export function registerPtyHandlers( } // Why: renderer scheduling hint only — active panes just get first chance at the bounded output reserve; reads/state/notifications continue for inactive terminals. if (args.active) { + if (activeRendererPtys.has(args.id)) { + return + } activeRendererPtys.add(args.id) - } else { - activeRendererPtys.delete(args.id) - } - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) + } else if (!activeRendererPtys.delete(args.id)) { + return } + invalidatePendingPtyDrainPriority(args.id) }) ipcMain.removeAllListeners('pty:setRendererPtyVisible') @@ -5182,13 +5640,13 @@ export function registerPtyHandlers( mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', { id: redactPtyIdForDiagnostics(args.id) }) + const transition = transitionHiddenRendererPtyDeliveryState(args.id, args.hidden === true) if (args.hidden === true) { - markHiddenRendererPty(args.id) closeStartupQueryAuthorityForPty(args.id) // Why: drop bytes queued for a newly hidden PTY instead of holding them under ACK starvation; reveal restores from the snapshot. const pending = pendingData.get(args.id) - if (pending && shouldDropHiddenRendererPtyData(args.id, getSettings?.())) { - deletePendingPtyData(args.id) + if (pending && transition.droppable) { + pendingData.delete(args.id) updateProducerFlowControl(args.id) pendingOverflowMarkedPtys.delete(args.id) const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) @@ -5200,13 +5658,18 @@ export function registerPtyHandlers( ) } } + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(args.id) + } syncPtyBackgroundedDelivery(args.id, 'gate-mark') return } - const { droppedWhileHidden } = unmarkHiddenRendererPty(args.id) + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(args.id) + } syncPtyBackgroundedDelivery(args.id, 'gate-unmark') // Why: a reload/remount may have replaced the view that latched restore-needed, so re-emit on unhide; a redundant replay is cheap/idempotent, a missed restore corrupts the pane. - if (droppedWhileHidden) { + if (transition.droppedWhileHidden) { sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id)) } }) @@ -5226,7 +5689,12 @@ export function registerPtyHandlers( return } // Why: any delivery interest suppresses the hidden-delivery gate (raw-byte consumers keep receiving while hidden); not synced to the daemon pacer so interest churn can't un-pace a flood. + const settings = getSettings?.() + const wasDroppable = shouldDropHiddenRendererPtyData(args.id, settings) setRendererPtyDeliveryInterest(args.id, args.interested === true) + if (wasDroppable !== shouldDropHiddenRendererPtyData(args.id, settings)) { + invalidatePendingPtyDrainPolicy(args.id) + } }) ipcMain.removeAllListeners('pty:signal') diff --git a/src/main/ipc/repo-target-directory-emptiness.ts b/src/main/ipc/repo-target-directory-emptiness.ts new file mode 100644 index 00000000000..2ecd505ff3a --- /dev/null +++ b/src/main/ipc/repo-target-directory-emptiness.ts @@ -0,0 +1,12 @@ +import { opendir } from 'node:fs/promises' + +export async function isRepoTargetDirectoryEmpty(targetPath: string): Promise { + const directory = await opendir(targetPath, { bufferSize: 1 }) + try { + return (await directory.read()) === null + } finally { + await directory.close().catch(() => { + // The OS may already have closed a fully consumed directory stream. + }) + } +} diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts new file mode 100644 index 00000000000..d6880848850 --- /dev/null +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -0,0 +1,193 @@ +/** + * Regression tests for repos:add + git worktrees. + * + * A linked worktree reports itself as its own `--show-toplevel`, so the path-based dedupe in + * addLocalRepoFromPath cannot see that it belongs to an already-tracked repo. Adding it anyway + * produced a second ready ProjectHostSetup on the same project and host — a duplicate "Local Mac" + * run-target row pointing at a transient worktree path. + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { Repo } from '../../shared/types' + +const { + handleMock, + removeHandlerMock, + mockStore, + isGitRepoMock, + getGitRepoRootMock, + getLinkedWorktreeMainRepoRootMock, + invalidateAuthorizedRootsCacheMock, + prepareLocalWorktreeRootForRepoMock, + detectRepoIconAndUpstreamMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeProject: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn() + }, + isGitRepoMock: vi.fn().mockReturnValue(true), + getGitRepoRootMock: vi.fn(), + getLinkedWorktreeMainRepoRootMock: vi.fn(), + invalidateAuthorizedRootsCacheMock: vi.fn(), + prepareLocalWorktreeRootForRepoMock: vi.fn(), + detectRepoIconAndUpstreamMock: vi.fn() +})) + +vi.mock('electron', () => ({ + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { handle: handleMock, removeHandler: removeHandlerMock } +})) + +vi.mock('../git/repo', () => ({ + isGitRepo: isGitRepoMock, + getGitRepoRoot: getGitRepoRootMock, + getLinkedWorktreeMainRepoRoot: getLinkedWorktreeMainRepoRootMock, + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), + searchBaseRefs: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../repo-detection', () => ({ + detectRepoIconAndUpstream: detectRepoIconAndUpstreamMock +})) + +vi.mock('./filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock +})) + +vi.mock('../worktree-root-preparation', () => ({ + prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) +vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn() })) + +import { registerRepoHandlers } from './repos' + +const MAIN_CHECKOUT = '/Users/dev/projects/orca' +const LINKED_WORKTREE = '/Users/dev/orca/workspaces/orca/pr-3235' + +type AddResult = { repo: Repo } | { error: string } + +describe('repos:add with git worktrees', () => { + const handlers = new Map unknown>() + const mockWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } + + const trackedMainRepo = (): Repo => + ({ + id: 'main-repo-id', + path: MAIN_CHECKOUT, + displayName: 'orca', + badgeColor: '#ef4444', + addedAt: 1, + kind: 'git' + }) as Repo + + const callAdd = (args: { path: string; kind?: 'git' | 'folder' }): Promise => { + const handler = handlers.get('repos:add') + if (!handler) { + throw new Error('repos:add handler was never registered') + } + return handler(null, args) as Promise + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) + }) + removeHandlerMock.mockReset() + mockStore.getRepos.mockReset().mockReturnValue([]) + mockStore.addRepo.mockReset() + isGitRepoMock.mockReset().mockReturnValue(true) + // A linked worktree is its own toplevel — this is exactly why path dedupe alone misses it. + getGitRepoRootMock.mockReset().mockImplementation((path: string) => path) + getLinkedWorktreeMainRepoRootMock.mockReset().mockReturnValue(null) + detectRepoIconAndUpstreamMock.mockReset().mockResolvedValue({}) + invalidateAuthorizedRootsCacheMock.mockReset() + prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) + + registerRepoHandlers(mockWindow as never, mockStore as never) + }) + + it('returns the tracked main checkout instead of adding its linked worktree', async () => { + mockStore.getRepos.mockReturnValue([trackedMainRepo()]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) + + const result = await callAdd({ path: LINKED_WORKTREE }) + + expect(result).toEqual({ repo: expect.objectContaining({ id: 'main-repo-id' }) }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('still adds a linked worktree whose main checkout is not tracked', async () => { + mockStore.getRepos.mockReturnValue([]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) + + const result = await callAdd({ path: LINKED_WORKTREE }) + + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + expect(result).toEqual({ repo: expect.objectContaining({ path: LINKED_WORKTREE }) }) + }) + + it('adds a normal repo when git reports it is not a linked worktree', async () => { + mockStore.getRepos.mockReturnValue([trackedMainRepo()]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(null) + + const result = await callAdd({ path: '/Users/dev/projects/other' }) + + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + expect(result).toEqual({ repo: expect.objectContaining({ path: '/Users/dev/projects/other' }) }) + }) + + it('does not consult worktree detection for folder projects', async () => { + mockStore.getRepos.mockReturnValue([trackedMainRepo()]) + + await callAdd({ path: '/Users/dev/notes', kind: 'folder' }) + + expect(getLinkedWorktreeMainRepoRootMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + }) + + it('matches the tracked main checkout across path separator differences', async () => { + mockStore.getRepos.mockReturnValue([ + { ...trackedMainRepo(), path: 'C:\\Users\\dev\\projects\\orca' } as Repo + ]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue('C:/Users/dev/projects/orca') + + const result = await callAdd({ path: 'C:/Users/dev/worktrees/pr-3235' }) + + expect(result).toEqual({ repo: expect.objectContaining({ id: 'main-repo-id' }) }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('does not match a folder record sitting on the main-checkout path', async () => { + mockStore.getRepos.mockReturnValue([ + { ...trackedMainRepo(), id: 'folder-repo-id', kind: 'folder' } as Repo + ]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) + + const result = await callAdd({ path: LINKED_WORKTREE }) + + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + expect(result).toEqual({ repo: expect.objectContaining({ path: LINKED_WORKTREE }) }) + }) + + it('does not match a tracked SSH repo that shares the local main-checkout path', async () => { + mockStore.getRepos.mockReturnValue([ + { ...trackedMainRepo(), id: 'ssh-repo-id', connectionId: 'builder' } as Repo + ]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) + + const result = await callAdd({ path: LINKED_WORKTREE }) + + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + expect(result).toEqual({ repo: expect.objectContaining({ path: LINKED_WORKTREE }) }) + }) +}) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 040f9348250..e00fb50b8f2 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -19,7 +19,9 @@ const { mockStore, mkdirMock, accessMock, - readdirMock, + opendirMock, + directoryReadMock, + directoryCloseMock, rmMock, gitExecFileAsyncMock, homedirMock, @@ -37,7 +39,9 @@ const { }, mkdirMock: vi.fn(), accessMock: vi.fn(), - readdirMock: vi.fn(), + opendirMock: vi.fn(), + directoryReadMock: vi.fn(), + directoryCloseMock: vi.fn(), rmMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), homedirMock: vi.fn(), @@ -56,7 +60,7 @@ vi.mock('electron', () => ({ vi.mock('fs/promises', () => ({ mkdir: mkdirMock, access: accessMock, - readdir: readdirMock, + opendir: opendirMock, rm: rmMock })) @@ -138,7 +142,12 @@ describe('repos:create', () => { // Default baseline: target does NOT exist yet, mkdir succeeds, git OK. accessMock.mockReset().mockRejectedValue(new Error('ENOENT')) - readdirMock.mockReset().mockResolvedValue([]) + directoryReadMock.mockReset().mockResolvedValue(null) + directoryCloseMock.mockReset().mockResolvedValue(undefined) + opendirMock.mockReset().mockResolvedValue({ + read: directoryReadMock, + close: directoryCloseMock + }) mkdirMock.mockReset().mockResolvedValue(undefined) rmMock.mockReset().mockResolvedValue(undefined) gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) @@ -203,7 +212,7 @@ describe('repos:create', () => { it('rejects a non-empty existing directory without creating the target', async () => { accessMock.mockResolvedValueOnce(undefined) // exists - readdirMock.mockResolvedValueOnce(['README.md', '.DS_Store']) + directoryReadMock.mockResolvedValueOnce({ name: 'README.md' }) const result = await callCreate({ parentPath: '/tmp', name: 'busy', kind: 'git' }) @@ -211,11 +220,12 @@ describe('repos:create', () => { expect(mkdirMock).toHaveBeenCalledWith('/tmp', { recursive: true }) expect(mkdirMock).not.toHaveBeenCalledWith('/tmp/busy', expect.anything()) expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(directoryReadMock).toHaveBeenCalledOnce() + expect(directoryCloseMock).toHaveBeenCalledOnce() }) it('accepts an empty existing directory and does not create the target', async () => { accessMock.mockResolvedValueOnce(undefined) // exists - readdirMock.mockResolvedValueOnce([]) const result = await callCreate({ parentPath: '/tmp', name: 'empty', kind: 'folder' }) @@ -314,7 +324,6 @@ describe('repos:create', () => { it('does NOT rm a pre-existing empty directory when git init fails', async () => { // Pretend the directory already existed (and is empty) — user pre-created it. accessMock.mockResolvedValueOnce(undefined) - readdirMock.mockResolvedValueOnce([]) gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('git init blew up')) const result = await callCreate({ parentPath: '/tmp', name: 'preexisting', kind: 'git' }) @@ -357,7 +366,6 @@ describe('repos:create', () => { // The folder itself must survive (user owns it) but the half-init'd // .git/ should be removed so the folder looks untouched. accessMock.mockResolvedValueOnce(undefined) - readdirMock.mockResolvedValueOnce([]) gitExecFileAsyncMock .mockReset() .mockResolvedValueOnce({ stdout: '', stderr: '' }) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index fd0ddcf770d..39577c75237 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -39,7 +39,7 @@ import { import { isTuiAgent } from '../../shared/tui-agent-config' import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'node:child_process' -import { access, mkdir, readdir, rm } from 'node:fs/promises' +import { access, mkdir, rm } from 'node:fs/promises' import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner' import { isAbsolute, join, posix } from 'node:path' import { @@ -59,6 +59,7 @@ import { createNestedRepoImportTargetResolver } from '../project-groups/nested-r import { isGitRepo, getGitRepoRoot, + getLinkedWorktreeMainRepoRoot, getRepoName, getBaseRefDefault, getRemoteCount, @@ -78,6 +79,7 @@ import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username' import { enrichRepoGitUsernames } from '../repo-git-username-enrichment' import { getActiveMultiplexer } from './ssh' import { normalizeSparseDirectories } from './sparse-checkout-directories' +import { isRepoTargetDirectoryEmpty } from './repo-target-directory-emptiness' import { track } from '../telemetry/client' import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' import { getCohortAtEmit } from '../telemetry/cohort-classifier' @@ -100,6 +102,7 @@ import { import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { runWithGitReadCacheInvalidation } from '../git/status' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' // Why: `method` is the IPC entry point the user took, not what they added (never path/URL/name); repos:create → 'folder_picker'. // Why: `isGitRepo` is a non-identifying git-vs-folder signal from the caller's detection; pass undefined when unknown, never default false. @@ -195,6 +198,29 @@ async function addLocalRepoFromPath( } } + // Why: a linked worktree reports itself as its own toplevel, so the path checks above can't see that + // it belongs to an already-tracked repo. Adding it anyway yields a second "ready" host setup on the + // same project and host — a duplicate run-target row that resolves to a transient worktree path. + if (repoKind === 'git') { + const mainRepoRoot = getLinkedWorktreeMainRepoRoot(resolvedPath) + if (mainRepoRoot) { + const mainRepoKey = normalizeRuntimePathForComparison(mainRepoRoot) + // Why !isFolderRepo: only a git-kind main checkout projects onto the same project as its + // worktree, so matching a folder record would suppress the add without deduping anything. + const trackedMainRepo = store + .getRepos() + .find( + (repo) => + !repo.connectionId && + !isFolderRepo(repo) && + normalizeRuntimePathForComparison(repo.path) === mainRepoKey + ) + if (trackedMainRepo) { + return { repo: trackedMainRepo, alreadyExisted: true } + } + } + } + const detected = await detectRepoIconAndUpstream({ repoPath: resolvedPath, kind: repoKind }) const repo: Repo = { id: randomUUID(), @@ -1092,7 +1118,11 @@ async function runNestedRepoScanForIpc( } } -export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void { +export function registerRepoHandlers( + mainWindow: BrowserWindow, + store: Store, + runtime?: OrcaRuntimeService +): void { // Remove previously registered handlers so we can re-register on macOS app re-activation (new window). ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:add') @@ -1739,8 +1769,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if (targetExists) { try { - const entries = await readdir(targetPath) - if (entries.length > 0) { + if (!(await isRepoTargetDirectoryEmpty(targetPath))) { return { error: `"${name}" already exists at this location and is not empty.` } @@ -1878,6 +1907,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { store.removeProject(args.repoId) + runtime?.notifyRepoStoreChanged(args.repoId) invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) }) @@ -1891,6 +1921,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v throw new Error(`Invalid host ID: ${args.hostId}`) } store.removeProjectForHost(args.repoId, hostId) + runtime?.notifyRepoStoreChanged(args.repoId) invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) } diff --git a/src/main/ipc/runtime-environment-call-queue.ts b/src/main/ipc/runtime-environment-call-queue.ts index 722f6fe8ada..a9f08e49ba6 100644 --- a/src/main/ipc/runtime-environment-call-queue.ts +++ b/src/main/ipc/runtime-environment-call-queue.ts @@ -5,7 +5,8 @@ const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() export function enqueueRuntimeCall( selector: string, method: string, - run: () => Promise + run: () => Promise, + retainedBytes = 0 ): Promise { - return runtimeCallQueuePool.enqueue(selector, method, run) + return runtimeCallQueuePool.enqueue(selector, method, run, retainedBytes) } diff --git a/src/main/ipc/runtime-environment-shared-control-support.ts b/src/main/ipc/runtime-environment-shared-control-support.ts new file mode 100644 index 00000000000..28abeede78c --- /dev/null +++ b/src/main/ipc/runtime-environment-shared-control-support.ts @@ -0,0 +1,81 @@ +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { markEnvironmentUsed } from '../../shared/runtime-environment-store' +import type { + getPreferredPairingOffer, + KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import type { RuntimeStatus } from '../../shared/runtime-types' + +const sharedControlSupport = new Map }>() + +export function resetSharedControlSupport(): void { + sharedControlSupport.clear() +} + +export function clearSharedControlSupport(environmentId: string): void { + sharedControlSupport.delete(environmentId) +} + +export async function supportsSharedControl( + userDataPath: string, + environment: KnownRuntimeEnvironment, + pairing: ReturnType, + timeoutMs: number +): Promise { + const cacheKey = getSharedControlSupportCacheKey(environment, pairing) + const cached = sharedControlSupport.get(environment.id) + if (cached?.cacheKey === cacheKey) { + return cached.check + } + let resolvedCacheKey = cacheKey + const check = (async () => { + const response = await sendRemoteRuntimeRequest( + pairing, + 'status.get', + undefined, + timeoutMs + ) + if (response.ok === true) { + markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) + resolvedCacheKey = getSharedControlSupportCacheKey( + environment, + pairing, + response._meta.runtimeId + ) + return ( + response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) === true + ) + } + return false + })() + // Why: the same saved host can be re-paired or point at a different runtime + // binary over time; capability support belongs to that pairing/runtime identity. + sharedControlSupport.set(environment.id, { cacheKey, check }) + try { + const supported = await check + const cachedAfterCheck = sharedControlSupport.get(environment.id) + if (cachedAfterCheck?.check === check && cachedAfterCheck.cacheKey !== resolvedCacheKey) { + sharedControlSupport.set(environment.id, { cacheKey: resolvedCacheKey, check }) + } + return supported + } catch (error) { + if (sharedControlSupport.get(environment.id)?.check === check) { + sharedControlSupport.delete(environment.id) + } + throw error + } +} + +function getSharedControlSupportCacheKey( + environment: KnownRuntimeEnvironment, + pairing: ReturnType, + runtimeId = environment.runtimeId +): string { + return [ + runtimeId ?? 'unknown-runtime', + pairing.endpoint, + pairing.deviceToken, + pairing.publicKeyB64 + ].join('\0') +} diff --git a/src/main/ipc/runtime-environment-subscription-admission.test.ts b/src/main/ipc/runtime-environment-subscription-admission.test.ts new file mode 100644 index 00000000000..2305ec33fb1 --- /dev/null +++ b/src/main/ipc/runtime-environment-subscription-admission.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES, + REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES, + REMOTE_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES, + REMOTE_RUNTIME_MAX_SUBSCRIPTIONS +} from '../../shared/remote-runtime-memory-limits' +import { createRuntimeEnvironmentSubscriptionAdmission } from './runtime-environment-subscription-admission' + +describe('runtime environment subscription admission', () => { + it('caps pending and active subscriptions, then recovers after release', () => { + const admission = createRuntimeEnvironmentSubscriptionAdmission() + const releases = Array.from({ length: REMOTE_RUNTIME_MAX_SUBSCRIPTIONS }, (_value, index) => + admission.claim(`subscription-${index}`, undefined) + ) + + expect(admission.evidence()).toEqual({ + retainedBytes: 0, + subscriptionCount: REMOTE_RUNTIME_MAX_SUBSCRIPTIONS + }) + expect(() => admission.claim('overflow', undefined)).toThrow('subscription limit reached') + + releases[0]?.() + expect(() => admission.claim('recovered', undefined)).not.toThrow() + }) + + it('caps aggregate retained params and releases the claim exactly once', () => { + const admission = createRuntimeEnvironmentSubscriptionAdmission() + const exactParams = 'x'.repeat(REMOTE_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES - 2) + const claimCount = + REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES / REMOTE_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES + const releases = Array.from({ length: claimCount }, (_value, index) => + admission.claim(`large-${index}`, exactParams) + ) + + expect(admission.evidence()).toEqual({ + retainedBytes: REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES, + subscriptionCount: claimCount + }) + expect(() => admission.claim('aggregate-overflow', null)).toThrow( + 'subscription memory limit reached' + ) + + releases[0]?.() + releases[0]?.() + const recovered = admission.claim('aggregate-recovered', exactParams) + expect(admission.evidence().retainedBytes).toBe(REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES) + recovered() + }) + + it('bounds caller-supplied ids by UTF-8 bytes and reserves duplicates', () => { + const admission = createRuntimeEnvironmentSubscriptionAdmission() + const exactId = 'é'.repeat(REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES / 2) + const release = admission.claim(exactId, undefined) + + expect(() => admission.claim(exactId, undefined)).toThrow('already exists') + expect(() => admission.claim(`${exactId}x`, undefined)).toThrow( + `between 1 and ${REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES} bytes` + ) + + release() + expect(() => admission.claim(exactId, undefined)).not.toThrow() + }) +}) diff --git a/src/main/ipc/runtime-environment-subscription-admission.ts b/src/main/ipc/runtime-environment-subscription-admission.ts new file mode 100644 index 00000000000..28a06588069 --- /dev/null +++ b/src/main/ipc/runtime-environment-subscription-admission.ts @@ -0,0 +1,69 @@ +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { + measureRemoteRuntimeSubscriptionParams, + REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES, + REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES, + REMOTE_RUNTIME_MAX_SUBSCRIPTIONS +} from '../../shared/remote-runtime-memory-limits' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export type RuntimeEnvironmentSubscriptionAdmission = { + claim: (subscriptionId: string, params: unknown) => () => void + evidence: () => { retainedBytes: number; subscriptionCount: number } +} + +export function createRuntimeEnvironmentSubscriptionAdmission(): RuntimeEnvironmentSubscriptionAdmission { + const subscriptionIds = new Set() + let retainedBytes = 0 + + return { + claim(subscriptionId, params): () => void { + assertSubscriptionId(subscriptionId) + if (subscriptionIds.has(subscriptionId)) { + throw new RemoteRuntimeClientError( + 'invalid_argument', + 'Runtime environment subscription id already exists.' + ) + } + if (subscriptionIds.size >= REMOTE_RUNTIME_MAX_SUBSCRIPTIONS) { + throw new RemoteRuntimeClientError( + 'remote_runtime_busy', + 'Remote runtime subscription limit reached; close a subscription and retry.' + ) + } + const paramsBytes = measureRemoteRuntimeSubscriptionParams(params) + if (retainedBytes + paramsBytes > REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES) { + throw new RemoteRuntimeClientError( + 'remote_runtime_busy', + 'Remote runtime subscription memory limit reached; close a subscription and retry.' + ) + } + subscriptionIds.add(subscriptionId) + retainedBytes += paramsBytes + let claimed = true + return () => { + if (!claimed) { + return + } + claimed = false + subscriptionIds.delete(subscriptionId) + retainedBytes -= paramsBytes + } + }, + evidence: () => ({ retainedBytes, subscriptionCount: subscriptionIds.size }) + } +} + +function assertSubscriptionId(subscriptionId: string): void { + if ( + subscriptionId.length === 0 || + measureUtf8ByteLength(subscriptionId, { + stopAfterBytes: REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES + }).exceededLimit + ) { + throw new RemoteRuntimeClientError( + 'invalid_argument', + `Runtime environment subscription id must be between 1 and ${REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES} bytes.` + ) + } +} diff --git a/src/main/ipc/runtime-environment-subscription-handlers.ts b/src/main/ipc/runtime-environment-subscription-handlers.ts new file mode 100644 index 00000000000..10d6c832fc6 --- /dev/null +++ b/src/main/ipc/runtime-environment-subscription-handlers.ts @@ -0,0 +1,238 @@ +import { ipcMain } from 'electron' +import { randomUUID } from 'node:crypto' +import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' +import { isRemoteRuntimeBinaryFrameWithinLimit } from '../../shared/remote-runtime-memory-limits' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { createRuntimeEnvironmentSubscriptionAdmission } from './runtime-environment-subscription-admission' +import { + retainRuntimeEnvironmentTransportGeneration, + type RuntimeEnvironmentTransportGenerationLease +} from './runtime-environment-transport-generation' +import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' + +type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { + environmentId: string + ownerWebContentsId: number + removeDestroyedListener: () => void + releaseOwnership: () => void +} + +const remoteRuntimeSubscriptions = new Map() + +export function closeAllRuntimeEnvironmentSubscriptions(): void { + for (const [subscriptionId, subscription] of Array.from(remoteRuntimeSubscriptions)) { + remoteRuntimeSubscriptions.delete(subscriptionId) + subscription.close() + } +} + +export function closeRuntimeEnvironmentSubscriptionsForEnvironment(environmentId: string): void { + for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { + if (subscription.environmentId !== environmentId) { + continue + } + remoteRuntimeSubscriptions.delete(subscriptionId) + subscription.close() + } +} + +export function registerRuntimeEnvironmentSubscriptionHandlers( + getUserDataPath: () => string +): void { + const subscriptionAdmission = createRuntimeEnvironmentSubscriptionAdmission() + ipcMain.handle( + 'runtimeEnvironments:subscribe', + async ( + event, + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + subscriptionId?: string + expectedEnvironmentPairingRevision?: number + } + ): Promise<{ subscriptionId: string; requestId: string }> => { + const subscriptionId = + typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 + ? args.subscriptionId + : randomUUID() + const releaseAdmission = subscriptionAdmission.claim(subscriptionId, args.params) + let transportLease: RuntimeEnvironmentTransportGenerationLease | null = null + let transportLeaseReleased = false + let transportWasCurrentAtRelease = true + const transportSetupIsCurrent = (): boolean => + transportLeaseReleased + ? transportWasCurrentAtRelease + : (transportLease?.isCurrent() ?? true) + const transportIsCurrent = (): boolean => + !transportLeaseReleased && (transportLease?.isCurrent() ?? true) + const releaseTransportLease = (): void => { + if (!transportLease || transportLeaseReleased) { + return + } + transportWasCurrentAtRelease = transportLease.isCurrent() + transportLeaseReleased = true + transportLease.release() + } + const releaseOwnership = (): void => { + releaseAdmission() + releaseTransportLease() + } + let ownershipTransferred = false + try { + if (remoteRuntimeSubscriptions.has(subscriptionId)) { + throw new Error('Runtime environment subscription id already exists') + } + const environment = resolveEnvironment(getUserDataPath(), args.selector) + const pairingRevision = environment.pairingRevision ?? environment.createdAt + if ( + args.expectedEnvironmentPairingRevision !== undefined && + pairingRevision !== args.expectedEnvironmentPairingRevision + ) { + throw new Error('Runtime environment pairing changed; refresh and try again') + } + transportLease = retainRuntimeEnvironmentTransportGeneration(environment.id) + const sender = event.sender + const ownerWebContentsId = sender.id + let senderDestroyed = sender.isDestroyed() + let transportClosed = false + let subscription: RemoteRuntimeSubscription | null = null + let destroyedListenerAttached = false + const removeDestroyedListener = (): void => { + if (!destroyedListenerAttached) { + return + } + destroyedListenerAttached = false + sender.removeListener('destroyed', closeSubscription) + } + const closeSubscription = (): void => { + senderDestroyed = true + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + remoteRuntimeSubscriptions.delete(subscriptionId) + if (retained) { + retained.close() + return + } + removeDestroyedListener() + subscription?.close() + releaseOwnership() + } + sender.once('destroyed', closeSubscription) + destroyedListenerAttached = true + try { + subscription = await subscribeRuntimeEnvironment( + getUserDataPath(), + environment.id, + args.method, + args.params, + args.timeoutMs, + { + onEvent: (payload) => { + if (transportIsCurrent() && !sender.isDestroyed()) { + sender.send('runtimeEnvironments:subscriptionEvent', { + subscriptionId, + ...payload + }) + } + }, + onClose: () => { + transportClosed = true + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + remoteRuntimeSubscriptions.delete(subscriptionId) + if (retained) { + retained.removeDestroyedListener() + retained.releaseOwnership() + return + } + removeDestroyedListener() + releaseOwnership() + } + } + ) + } catch (error) { + removeDestroyedListener() + throw error + } + let pairingIsCurrent = false + try { + const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) + pairingIsCurrent = + (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision + } catch { + pairingIsCurrent = false + } + if (!transportSetupIsCurrent() || !pairingIsCurrent) { + removeDestroyedListener() + subscription.close() + throw new Error('Runtime environment pairing changed; refresh and try again') + } + if (senderDestroyed || transportClosed || sender.isDestroyed()) { + removeDestroyedListener() + subscription.close() + return { subscriptionId, requestId: subscription.requestId } + } + remoteRuntimeSubscriptions.set(subscriptionId, { + requestId: subscription.requestId, + environmentId: environment.id, + ownerWebContentsId, + removeDestroyedListener, + releaseOwnership, + sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, + close: () => { + removeDestroyedListener() + releaseOwnership() + subscription?.close() + } + }) + ownershipTransferred = true + return { subscriptionId, requestId: subscription.requestId } + } finally { + if (!ownershipTransferred) { + releaseOwnership() + } + } + } + ) + ipcMain.handle( + 'runtimeEnvironments:unsubscribe', + (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { + return { unsubscribed: false } + } + remoteRuntimeSubscriptions.delete(args.subscriptionId) + subscription.close() + return { unsubscribed: true } + } + ) + ipcMain.on( + 'runtimeEnvironments:subscriptionBinary', + (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { + if (typeof args.subscriptionId !== 'string') { + return + } + const bytes = toBinaryPayload(args.bytes) + if (!bytes || !isRemoteRuntimeBinaryFrameWithinLimit(bytes)) { + return + } + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (subscription?.ownerWebContentsId === event.sender.id) { + subscription.sendBinary(bytes) + } + } + ) +} + +function toBinaryPayload(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) { + return value + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value) + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + return null +} diff --git a/src/main/ipc/runtime-environment-transport-generation.test.ts b/src/main/ipc/runtime-environment-transport-generation.test.ts new file mode 100644 index 00000000000..664ed842fd9 --- /dev/null +++ b/src/main/ipc/runtime-environment-transport-generation.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _internals, + advanceRuntimeEnvironmentTransportGeneration, + retainRuntimeEnvironmentTransportGeneration +} from './runtime-environment-transport-generation' + +afterEach(() => { + _internals.resetForTest() +}) + +describe('runtime environment transport generations', () => { + it('invalidates retained transports and releases their environment entry', () => { + const lease = retainRuntimeEnvironmentTransportGeneration('environment-1') + + expect(lease.isCurrent()).toBe(true) + advanceRuntimeEnvironmentTransportGeneration('environment-1') + expect(lease.isCurrent()).toBe(false) + + lease.release() + expect(_internals.trackedEnvironmentCountForTest()).toBe(0) + }) + + it('retains a shared environment entry until its final lease closes', () => { + const first = retainRuntimeEnvironmentTransportGeneration('environment-1') + const second = retainRuntimeEnvironmentTransportGeneration('environment-1') + + first.release() + expect(second.isCurrent()).toBe(true) + expect(_internals.trackedEnvironmentCountForTest()).toBe(1) + + second.release() + expect(_internals.trackedEnvironmentCountForTest()).toBe(0) + }) + + it('does not accumulate tombstones under sequential unique-environment churn', () => { + for (let index = 0; index < 10_000; index += 1) { + const environmentId = `environment-${index}` + const lease = retainRuntimeEnvironmentTransportGeneration(environmentId) + advanceRuntimeEnvironmentTransportGeneration(environmentId) + lease.release() + } + + expect(_internals.trackedEnvironmentCountForTest()).toBe(0) + }) + + it('does not allocate tombstones when invalidating environments without transports', () => { + for (let index = 0; index < 10_000; index += 1) { + advanceRuntimeEnvironmentTransportGeneration(`environment-${index}`) + } + + expect(_internals.trackedEnvironmentCountForTest()).toBe(0) + }) +}) diff --git a/src/main/ipc/runtime-environment-transport-generation.ts b/src/main/ipc/runtime-environment-transport-generation.ts index e39c86a52df..263b6dbc7a4 100644 --- a/src/main/ipc/runtime-environment-transport-generation.ts +++ b/src/main/ipc/runtime-environment-transport-generation.ts @@ -1,12 +1,71 @@ -const generationByEnvironment = new Map() +type RuntimeEnvironmentTransportGenerationEntry = { + token: number + leaseCount: number +} -export function getRuntimeEnvironmentTransportGeneration(environmentId: string): number { - return generationByEnvironment.get(environmentId) ?? 0 +export type RuntimeEnvironmentTransportGenerationLease = { + isCurrent: () => boolean + release: () => void +} + +const generationByEnvironment = new Map() +let nextGenerationToken = 1 + +function allocateGenerationToken(): number { + if (!Number.isSafeInteger(nextGenerationToken)) { + throw new Error('Runtime environment transport generation exhausted') + } + const token = nextGenerationToken + nextGenerationToken += 1 + return token +} + +export function retainRuntimeEnvironmentTransportGeneration( + environmentId: string +): RuntimeEnvironmentTransportGenerationLease { + let entry = generationByEnvironment.get(environmentId) + if (!entry) { + entry = { token: allocateGenerationToken(), leaseCount: 0 } + generationByEnvironment.set(environmentId, entry) + } + entry.leaseCount += 1 + const retainedEntry = entry + const retainedToken = entry.token + let retained = true + return { + isCurrent: () => + retained && + generationByEnvironment.get(environmentId) === retainedEntry && + retainedEntry.token === retainedToken, + release: () => { + if (!retained) { + return + } + retained = false + retainedEntry.leaseCount -= 1 + if ( + retainedEntry.leaseCount === 0 && + generationByEnvironment.get(environmentId) === retainedEntry + ) { + generationByEnvironment.delete(environmentId) + } + } + } } export function advanceRuntimeEnvironmentTransportGeneration(environmentId: string): void { - generationByEnvironment.set( - environmentId, - getRuntimeEnvironmentTransportGeneration(environmentId) + 1 - ) + const entry = generationByEnvironment.get(environmentId) + if (entry) { + entry.token = allocateGenerationToken() + } +} + +export const _internals = { + trackedEnvironmentCountForTest(): number { + return generationByEnvironment.size + }, + resetForTest(): void { + generationByEnvironment.clear() + nextGenerationToken = 1 + } } diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 46241a82521..efde61ccd5c 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,11 +1,8 @@ -import { - getPreferredPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { serializeRemoteRuntimeRpcRequest } from '../../shared/remote-runtime-memory-limits' import type { RuntimeStatus } from '../../shared/runtime-types' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest, @@ -22,14 +19,14 @@ import { import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' +import { supportsSharedControl } from './runtime-environment-shared-control-support' + +export { + clearSharedControlSupport, + resetSharedControlSupport +} from './runtime-environment-shared-control-support' const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 -const sharedControlSupport = new Map }>() - -export const resetSharedControlSupport = (): void => sharedControlSupport.clear() - -export const clearSharedControlSupport = (environmentId: string): void => - void sharedControlSupport.delete(environmentId) export async function getRuntimeEnvironmentStatus( userDataPath: string, @@ -84,58 +81,78 @@ export async function callRuntimeEnvironment( expectedEnvironmentPairingRevision?: number ): Promise> { const environment = resolveEnvironment(userDataPath, selector) + const initialPairing = getPreferredPairingOffer(environment) + const retainedBytes = Buffer.byteLength( + serializeRemoteRuntimeRpcRequest({ + requestId: '00000000-0000-4000-8000-000000000000', + deviceToken: initialPairing.deviceToken, + method, + params + }), + 'utf8' + ) // Why: connection failures reject (they don't resolve as ok:false), so the // Tailscale hint is applied to the thrown error here — wrapping the resolved // value would miss the in-use connect/timeout case the toast surfaces. // Track the endpoint the queued closure actually used: it re-resolves the // environment, so a re-pair between enqueue and dispatch can change it. - let endpoint = getPreferredPairingOffer(environment).endpoint + let endpoint = initialPairing.endpoint try { - return await enqueueRuntimeCall(environment.id, method, async () => { - const currentEnvironment = resolveEnvironment(userDataPath, environment.id) - const revisionFailure = runtimeEnvironmentRevisionFailure( - currentEnvironment, - expectedEnvironmentPairingRevision, - method - ) - if (revisionFailure) { - return revisionFailure - } - const pairing = getPreferredPairingOffer(currentEnvironment) - endpoint = pairing.endpoint - const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS - if (shouldUseCachedRequestConnection(method)) { - const response = await sendRemoteRuntimeConnectionRequest( - currentEnvironment.id, - pairing, - method, - params, - effectiveTimeoutMs + return await enqueueRuntimeCall( + environment.id, + method, + async () => { + const currentEnvironment = resolveEnvironment(userDataPath, environment.id) + const revisionFailure = runtimeEnvironmentRevisionFailure( + currentEnvironment, + expectedEnvironmentPairingRevision, + method ) + if (revisionFailure) { + return revisionFailure + } + const pairing = getPreferredPairingOffer(currentEnvironment) + endpoint = pairing.endpoint + const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS + if (shouldUseCachedRequestConnection(method)) { + const response = await sendRemoteRuntimeConnectionRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs + ) + markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) + return response + } + if ( + method !== 'status.get' && + !shouldUseOneShotRequest(method) && + (await supportsSharedControl( + userDataPath, + currentEnvironment, + pairing, + effectiveTimeoutMs + )) + ) { + const response = await sendRemoteRuntimeSharedControlRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs + ) + markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) + return response + } + // Why: startup/control-plane RPCs use the proven one-shot path so repo + // hydration cannot be coupled to a stale terminal-control connection. + const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) return response - } - if ( - method !== 'status.get' && - !shouldUseOneShotRequest(method) && - (await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs)) - ) { - const response = await sendRemoteRuntimeSharedControlRequest( - currentEnvironment.id, - pairing, - method, - params, - effectiveTimeoutMs - ) - markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) - return response - } - // Why: startup/control-plane RPCs use the proven one-shot path so repo - // hydration cannot be coupled to a stale terminal-control connection. - const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) - markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) - return response - }) + }, + retainedBytes + ) } catch (error) { if (error instanceof Error) { error.message = withRemoteRuntimeTailscaleHint(error.message, endpoint) @@ -257,66 +274,3 @@ function shouldUseSharedControlSubscription(method: string): boolean { method === 'files.watch' ) } - -async function supportsSharedControl( - userDataPath: string, - environment: KnownRuntimeEnvironment, - pairing: ReturnType, - timeoutMs: number -): Promise { - const cacheKey = getSharedControlSupportCacheKey(environment, pairing) - const cached = sharedControlSupport.get(environment.id) - if (cached?.cacheKey === cacheKey) { - return cached.check - } - let resolvedCacheKey = cacheKey - const check = (async () => { - const response = await sendRemoteRuntimeRequest( - pairing, - 'status.get', - undefined, - timeoutMs - ) - if (response.ok === true) { - markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) - resolvedCacheKey = getSharedControlSupportCacheKey( - environment, - pairing, - response._meta.runtimeId - ) - return ( - response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) === true - ) - } - return false - })() - // Why: the same saved host can be re-paired or point at a different runtime - // binary over time; capability support belongs to that pairing/runtime identity. - sharedControlSupport.set(environment.id, { cacheKey, check }) - try { - const supported = await check - const cachedAfterCheck = sharedControlSupport.get(environment.id) - if (cachedAfterCheck?.check === check && cachedAfterCheck.cacheKey !== resolvedCacheKey) { - sharedControlSupport.set(environment.id, { cacheKey: resolvedCacheKey, check }) - } - return supported - } catch (error) { - if (sharedControlSupport.get(environment.id)?.check === check) { - sharedControlSupport.delete(environment.id) - } - throw error - } -} - -function getSharedControlSupportCacheKey( - environment: KnownRuntimeEnvironment, - pairing: ReturnType, - runtimeId = environment.runtimeId -): string { - return [ - runtimeId ?? 'unknown-runtime', - pairing.endpoint, - pairing.deviceToken, - pairing.publicKeyB64 - ].join('\0') -} diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 5a0cd612864..ee90414938a 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -1381,6 +1381,56 @@ describe('registerRuntimeEnvironmentHandlers', () => { markUsedSpy.mockRestore() }) + it('drops a queued old-peer frame after unsubscribe and transport invalidation', async () => { + registerRuntimeEnvironmentHandlers(store as never) + const close = vi.fn() + subscribeRemoteRuntimeRequestMock.mockResolvedValue({ + requestId: 'queued-old-peer', + close, + sendBinary: vi.fn() + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + const senderSend = vi.fn() + const subscribe = handler< + { selector: string; method: string; subscriptionId: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const result = await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + }, + { + selector: added.environment.id, + method: 'terminal.subscribe', + subscriptionId: 'queued-old-peer' + } + ) + const callbacks = subscribeRemoteRuntimeRequestMock.mock.calls[0]![4] as { + onBinary: (bytes: Uint8Array) => void + } + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + + expect(await unsubscribe({ sender: { id: 1 } }, result)).toEqual({ unsubscribed: true }) + invalidateRuntimeEnvironmentTransport(added.environment.id) + callbacks.onBinary(new Uint8Array([1, 2, 3])) + + expect(close).toHaveBeenCalledTimes(1) + expect(senderSend).not.toHaveBeenCalled() + }) + it('closes streaming subscriptions when their saved runtime is removed', async () => { registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 9662b8ab598..5e5cede71b2 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -1,5 +1,4 @@ import { app, ipcMain } from 'electron' -import { randomUUID } from 'node:crypto' import { addEnvironmentFromPairingCode, listEnvironments, @@ -12,20 +11,20 @@ import { } from '../../shared/runtime-environments' import type { RuntimeStatus } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' -import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import type { Store } from '../persistence' import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' -import { - advanceRuntimeEnvironmentTransportGeneration, - getRuntimeEnvironmentTransportGeneration -} from './runtime-environment-transport-generation' +import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' import { callRuntimeEnvironment, clearSharedControlSupport, getRuntimeEnvironmentStatus, - resetSharedControlSupport, - subscribeRuntimeEnvironment + resetSharedControlSupport } from './runtime-environment-transport-routing' +import { + closeAllRuntimeEnvironmentSubscriptions, + closeRuntimeEnvironmentSubscriptionsForEnvironment, + registerRuntimeEnvironmentSubscriptionHandlers +} from './runtime-environment-subscription-handlers' const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:list', @@ -39,30 +38,14 @@ const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:unsubscribe' ] as const -type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { - environmentId: string - ownerWebContentsId: number - removeDestroyedListener: () => void -} -const remoteRuntimeSubscriptions = new Map() const getUserDataPath = (): string => app.getPath('userData') -function closeSubscriptionsForEnvironment(environmentId: string): void { - // Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown. - for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { - if (subscription.environmentId !== environmentId) { - continue - } - remoteRuntimeSubscriptions.delete(subscriptionId) - subscription.close() - } -} export function invalidateRuntimeEnvironmentTransport(environmentId: string): void { // Why: a same-id re-pair must retire every transport that still authenticates as the old peer. advanceRuntimeEnvironmentTransportGeneration(environmentId) closeRemoteRuntimeRequestConnection(environmentId) clearSharedControlSupport(environmentId) - closeSubscriptionsForEnvironment(environmentId) + closeRuntimeEnvironmentSubscriptionsForEnvironment(environmentId) } function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] { @@ -74,6 +57,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { // Why: keep direct re-registration safe even though register-core-handlers // normally guards this path; otherwise the binary send listener can stack. resetSharedControlSupport() + closeAllRuntimeEnvironmentSubscriptions() for (const channel of RUNTIME_ENVIRONMENT_HANDLER_CHANNELS) { ipcMain.removeHandler(channel) } @@ -153,160 +137,5 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { ) } ) - ipcMain.handle( - 'runtimeEnvironments:subscribe', - async ( - event, - args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - subscriptionId?: string - expectedEnvironmentPairingRevision?: number - } - ): Promise<{ subscriptionId: string; requestId: string }> => { - const subscriptionId = - typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 - ? args.subscriptionId - : randomUUID() - if (remoteRuntimeSubscriptions.has(subscriptionId)) { - throw new Error('Runtime environment subscription id already exists') - } - const environment = resolveEnvironment(getUserDataPath(), args.selector) - const pairingRevision = environment.pairingRevision ?? environment.createdAt - if ( - args.expectedEnvironmentPairingRevision !== undefined && - pairingRevision !== args.expectedEnvironmentPairingRevision - ) { - throw new Error('Runtime environment pairing changed; refresh and try again') - } - const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id) - const transportIsCurrent = (): boolean => - getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration - const sender = event.sender - const ownerWebContentsId = sender.id - let senderDestroyed = sender.isDestroyed() - let subscription: RemoteRuntimeSubscription | null = null - let destroyedListenerAttached = false - const removeDestroyedListener = (): void => { - if (!destroyedListenerAttached) { - return - } - destroyedListenerAttached = false - sender.removeListener('destroyed', closeSubscription) - } - const closeSubscription = (): void => { - senderDestroyed = true - const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null - remoteRuntimeSubscriptions.delete(subscriptionId) - if (retained) { - retained.close() - return - } - removeDestroyedListener() - subscription?.close() - } - sender.once('destroyed', closeSubscription) - destroyedListenerAttached = true - try { - subscription = await subscribeRuntimeEnvironment( - getUserDataPath(), - environment.id, - args.method, - args.params, - args.timeoutMs, - { - onEvent: (payload) => { - if (transportIsCurrent() && !sender.isDestroyed()) { - sender.send('runtimeEnvironments:subscriptionEvent', { - subscriptionId, - ...payload - }) - } - }, - onClose: () => { - const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null - retained?.removeDestroyedListener() - remoteRuntimeSubscriptions.delete(subscriptionId) - } - } - ) - } catch (error) { - removeDestroyedListener() - throw error - } - let pairingIsCurrent = false - try { - const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) - pairingIsCurrent = - (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision - } catch { - pairingIsCurrent = false - } - if (!transportIsCurrent() || !pairingIsCurrent) { - removeDestroyedListener() - subscription.close() - throw new Error('Runtime environment pairing changed; refresh and try again') - } - if (senderDestroyed || sender.isDestroyed()) { - removeDestroyedListener() - subscription.close() - return { subscriptionId, requestId: subscription.requestId } - } - remoteRuntimeSubscriptions.set(subscriptionId, { - requestId: subscription.requestId, - environmentId: environment.id, - ownerWebContentsId, - removeDestroyedListener, - sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, - close: () => { - removeDestroyedListener() - subscription?.close() - } - }) - return { subscriptionId, requestId: subscription.requestId } - } - ) - ipcMain.handle( - 'runtimeEnvironments:unsubscribe', - (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { - return { unsubscribed: false } - } - remoteRuntimeSubscriptions.delete(args.subscriptionId) - subscription.close() - return { unsubscribed: true } - } - ) - ipcMain.on( - 'runtimeEnvironments:subscriptionBinary', - (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { - if (typeof args.subscriptionId !== 'string') { - return - } - const bytes = toBinaryPayload(args.bytes) - if (!bytes) { - return - } - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (subscription?.ownerWebContentsId === event.sender.id) { - subscription.sendBinary(bytes) - } - } - ) -} - -function toBinaryPayload(value: unknown): Uint8Array | null { - if (value instanceof Uint8Array) { - return value - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value) - } - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) - } - return null + registerRuntimeEnvironmentSubscriptionHandlers(getUserDataPath) } diff --git a/src/main/ipc/shell-repo-icon-picker.ts b/src/main/ipc/shell-repo-icon-picker.ts new file mode 100644 index 00000000000..5f26f7657ce --- /dev/null +++ b/src/main/ipc/shell-repo-icon-picker.ts @@ -0,0 +1,52 @@ +import { dialog } from 'electron' +import { stat } from 'node:fs/promises' +import { basename, extname } from 'node:path' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' +import { assertRasterImagePreviewWithinLimits } from '../../shared/raster-image-preview-limits' +import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon' + +const REPO_ICON_IMAGE_MIME_TYPES: Record = { + '.png': 'image/png' +} + +export async function pickRepoIconImage(): Promise<{ + dataUrl: string + fileName: string +} | null> { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Repo icon images', extensions: ['png'] }] + }) + if (result.canceled || result.filePaths.length === 0) { + return null + } + + const filePath = result.filePaths[0] + const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] + if (!mimeType) { + throw new Error('Repo icons must be PNG files.') + } + + const stats = await stat(filePath) + if (stats.size > MAX_REPO_ICON_UPLOAD_BYTES) { + throw new Error('Repo icon image must be 256KB or smaller.') + } + + let buffer: Buffer + try { + buffer = (await readNodeFileWithinLimit(filePath, MAX_REPO_ICON_UPLOAD_BYTES)).buffer + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw new Error('Repo icon image must be 256KB or smaller.') + } + throw error + } + assertRasterImagePreviewWithinLimits(buffer, mimeType) + return { + dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`, + fileName: basename(filePath) + } +} diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 7193da4beaa..28a7839b39a 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -1,12 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { normalize, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import * as NodeBoundedFileReader from '../../shared/memory-safety/node-bounded-file-reader' const { getSpawnArgsForWindowsMock, handleMock, openPathMock, + readNodeFileWithinLimitMock, resolveCliCommandMock, + readFileMock, showItemInFolderMock, showOpenDialogMock, spawnMock, @@ -15,7 +18,9 @@ const { getSpawnArgsForWindowsMock: vi.fn(), handleMock: vi.fn(), openPathMock: vi.fn(), + readNodeFileWithinLimitMock: vi.fn(), resolveCliCommandMock: vi.fn(), + readFileMock: vi.fn(), showItemInFolderMock: vi.fn(), showOpenDialogMock: vi.fn(), spawnMock: vi.fn(), @@ -42,6 +47,11 @@ vi.mock('node:fs/promises', () => ({ stat: statMock })) +vi.mock('../../shared/memory-safety/node-bounded-file-reader', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readNodeFileWithinLimit: readNodeFileWithinLimitMock } +}) + vi.mock('node:child_process', () => ({ spawn: spawnMock })) @@ -105,6 +115,11 @@ describe('registerShellHandlers', () => { getSpawnArgsForWindowsMock.mockReset() openPathMock.mockReset() resolveCliCommandMock.mockReset() + readFileMock.mockReset() + readNodeFileWithinLimitMock.mockReset() + readNodeFileWithinLimitMock.mockImplementation(async (path: string) => ({ + buffer: await readFileMock(path) + })) showItemInFolderMock.mockReset() showOpenDialogMock.mockReset() spawnMock.mockReset() @@ -154,6 +169,32 @@ describe('registerShellHandlers', () => { await expect(handler({})).resolves.toBeNull() }) + it('rejects a repo icon raster dimension bomb before returning a data URL', async () => { + const bytes = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) + bytes.writeUInt32BE(13, 8) + bytes.write('IHDR', 12, 'ascii') + bytes.writeUInt32BE(32_769, 16) + bytes.writeUInt32BE(1, 20) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/tmp/bomb.png'] }) + statMock.mockResolvedValue({ size: bytes.length }) + readFileMock.mockResolvedValue(bytes) + + const handler = getHandler('shell:pickRepoIconImage') + await expect(handler({})).rejects.toThrow('Image dimensions exceed the preview safety limit') + }) + + it('rejects a repo icon that grows past the byte cap after its size check', async () => { + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/tmp/growing.png'] }) + statMock.mockResolvedValue({ size: 1 }) + readNodeFileWithinLimitMock.mockRejectedValue( + new NodeBoundedFileReader.NodeFileReadTooLargeError(256 * 1024 + 1, 256 * 1024) + ) + + const handler = getHandler('shell:pickRepoIconImage') + await expect(handler({})).rejects.toThrow('Repo icon image must be 256KB or smaller.') + }) + it('picks an existing directory without enabling native directory creation', async () => { showOpenDialogMock.mockResolvedValue({ canceled: false, diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 760b32eed2a..60a04205447 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -1,14 +1,13 @@ import { ipcMain, shell, dialog } from 'electron' import { spawn } from 'node:child_process' -import { constants, copyFile, readFile, stat } from 'node:fs/promises' -import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path' +import { constants, copyFile, stat } from 'node:fs/promises' +import { isAbsolute, normalize, posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import type { ShellOpenExternalEditorRequest, ShellOpenExternalEditorResult, ShellOpenLocalPathResult } from '../../shared/shell-open-types' -import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon' import type { Store } from '../persistence' import { getSpawnArgsForWindows } from '../win32-utils' import { @@ -18,13 +17,10 @@ import { type ExternalEditorLaunchSpec } from '../external-editor-launch' import { resolveVsCodeSshAuthority } from '../ssh/vscode-ssh-authority' +import { pickRepoIconImage } from './shell-repo-icon-picker' export { EXTERNAL_EDITOR_CLI_COMMAND } -const REPO_ICON_IMAGE_MIME_TYPES: Record = { - '.png': 'image/png' -} - async function pathExists(pathValue: string): Promise { try { await stat(pathValue) @@ -296,36 +292,7 @@ export function registerShellHandlers(store: Store): void { return result.filePaths[0] }) - ipcMain.handle( - 'shell:pickRepoIconImage', - async (): Promise<{ dataUrl: string; fileName: string } | null> => { - const result = await dialog.showOpenDialog({ - properties: ['openFile'], - filters: [{ name: 'Repo icon images', extensions: ['png'] }] - }) - if (result.canceled || result.filePaths.length === 0) { - return null - } - - const filePath = result.filePaths[0] - const extension = extname(filePath).toLowerCase() - const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extension] - if (!mimeType) { - throw new Error('Repo icons must be PNG files.') - } - - const stats = await stat(filePath) - if (stats.size > MAX_REPO_ICON_UPLOAD_BYTES) { - throw new Error('Repo icon image must be 256KB or smaller.') - } - - const buffer = await readFile(filePath) - return { - dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`, - fileName: basename(filePath) - } - } - ) + ipcMain.handle('shell:pickRepoIconImage', pickRepoIconImage) ipcMain.handle('shell:pickAudio', async (): Promise => { const result = await dialog.showOpenDialog({ diff --git a/src/main/ipc/source-control-ai-linked-issue.test.ts b/src/main/ipc/source-control-ai-linked-issue.test.ts new file mode 100644 index 00000000000..1a5291fc6d7 --- /dev/null +++ b/src/main/ipc/source-control-ai-linked-issue.test.ts @@ -0,0 +1,173 @@ +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import { resolveSourceControlAiLinkedIssue } from './source-control-ai-linked-issue' + +const LOCAL_PATH = path.resolve('/workspace/repo-feature') +const LOCAL_ID = `repo-1::${LOCAL_PATH}` +const REMOTE_PATH = '/home/tester/wt' +const REMOTE_ID = `repo-1::${REMOTE_PATH}` + +function makeStore(meta: Record): Store { + return { + getWorktreeMeta: vi.fn((worktreeId: string) => meta[worktreeId]) + } as unknown as Store +} + +describe('resolveSourceControlAiLinkedIssue', () => { + it('reads meta with the raw id when the id matches the request path', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 123 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH, + repoId: 'repo-1' + }) + ).toBe(123) + expect(store.getWorktreeMeta).toHaveBeenCalledWith(LOCAL_ID) + }) + + it('keeps the folder-repo instance suffix in the meta key while validating the stripped path', () => { + const instanceId = `${LOCAL_ID}::workspace:${'0'.repeat(8)}-0000-0000-0000-${'0'.repeat(12)}` + const store = makeStore({ [instanceId]: { linkedIssue: 9 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: instanceId, + worktreePath: LOCAL_PATH + }) + ).toBe(9) + expect(store.getWorktreeMeta).toHaveBeenCalledWith(instanceId) + }) + + // Why: the desktop renderer derives `worktreePath` from `worktreeId`, so it can + // never send a mismatched pair — these cases model an independent caller (relay, + // CLI, future in-process caller) and assert the guard fails closed for them. + // They are not evidence that a stale renderer context is rejected; it is not. + it('rejects an independently supplied id whose path does not match the request', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 123 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: LOCAL_ID, + worktreePath: path.resolve('/workspace/repo-other') + }) + ).toBeNull() + expect(store.getWorktreeMeta).not.toHaveBeenCalled() + }) + + it('accepts the resolved worktree path as an alternate local candidate', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 5 } }) + + expect( + resolveSourceControlAiLinkedIssue( + store, + { worktreeId: LOCAL_ID, worktreePath: path.resolve('/workspace/symlinked') }, + LOCAL_PATH + ) + ).toBe(5) + }) + + it('rejects an independently supplied id whose repoId contradicts the request repoId', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 123 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH, + repoId: 'repo-2' + }) + ).toBeNull() + expect(store.getWorktreeMeta).not.toHaveBeenCalled() + }) + + it('fails closed on an empty-string repoId instead of skipping the cross-check', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 123 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH, + repoId: '' + }) + ).toBeNull() + expect(store.getWorktreeMeta).not.toHaveBeenCalled() + }) + + it('compares SSH remote paths as raw strings', () => { + const store = makeStore({ [REMOTE_ID]: { linkedIssue: 77 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: REMOTE_ID, + worktreePath: `${REMOTE_PATH}/`, + connectionId: 'conn-1' + }) + ).toBe(77) + }) + + it('matches SSH remote paths from a Windows host without path rewriting', () => { + const original = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const store = makeStore({ [REMOTE_ID]: { linkedIssue: 77 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: REMOTE_ID, + worktreePath: REMOTE_PATH, + connectionId: 'conn-1' + }) + ).toBe(77) + } finally { + Object.defineProperty(process, 'platform', original) + } + }) + + it('returns null without touching the store when no id is supplied', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 123 } }) + + expect(resolveSourceControlAiLinkedIssue(store, { worktreePath: LOCAL_PATH })).toBeNull() + expect(store.getWorktreeMeta).not.toHaveBeenCalled() + }) + + it('tolerates a store without a meta accessor', () => { + expect( + resolveSourceControlAiLinkedIssue({} as Store, { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH + }) + ).toBeNull() + }) + + it('returns null for unparsable ids and unlinked or unusable meta', () => { + expect( + resolveSourceControlAiLinkedIssue(makeStore({}), { + worktreeId: 'no-separator', + worktreePath: LOCAL_PATH + }) + ).toBeNull() + for (const linkedIssue of [null, undefined, Number.NaN, 0, -7, 12.9]) { + expect( + resolveSourceControlAiLinkedIssue(makeStore({ [LOCAL_ID]: { linkedIssue } }), { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH + }) + ).toBeNull() + } + }) + + it('does not fall back to a GitLab-linked issue', () => { + const store = { + getWorktreeMeta: vi.fn(() => ({ linkedIssue: null, linkedGitLabIssue: 456 })) + } as unknown as Store + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH + }) + ).toBeNull() + }) +}) diff --git a/src/main/ipc/source-control-ai-linked-issue.ts b/src/main/ipc/source-control-ai-linked-issue.ts new file mode 100644 index 00000000000..559d39c7399 --- /dev/null +++ b/src/main/ipc/source-control-ai-linked-issue.ts @@ -0,0 +1,83 @@ +import { resolve } from 'node:path' +import type { Store } from '../persistence' +import { isLinkedIssueNumber } from '../../shared/source-control-ai-action-variables' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' + +export type LinkedIssueLookupArgs = { + worktreeId?: string + worktreePath: string + repoId?: string + connectionId?: string +} + +function trimTrailingSeparators(value: string): string { + return value.replace(/[\\/]+$/g, '') +} + +function comparableLocalPath(value: string): string { + const normalized = resolve(value) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function matchesRequestPath( + idWorktreePath: string, + args: LinkedIssueLookupArgs, + resolvedWorktreePath: string | undefined +): boolean { + if (args.connectionId) { + // Why: the SSH branch never resolves the path, so it is a remote POSIX string. + // resolve()/case-folding it from a Windows host would rewrite it and never match. + return trimTrailingSeparators(idWorktreePath) === trimTrailingSeparators(args.worktreePath) + } + const candidates = new Set( + [args.worktreePath, resolvedWorktreePath ?? args.worktreePath].map(comparableLocalPath) + ) + return candidates.has(comparableLocalPath(idWorktreePath)) +} + +/** + * Resolve the workspace's linked GitHub issue for Source Control AI generation. + * + * The renderer-supplied `worktreeId` is advisory — the same trust model as + * `getRepoForSourceControlAi` — so it is validated against the request's path + * (and `repoId`) before the meta read. + * + * Scope of that guarantee: today's desktop renderer derives `worktreePath` from + * `worktreeId` (`resolveLocalWorktreePath`), so its two operands always agree and + * this check cannot reject a renderer call — including a stale id after a + * workspace switch, which produces a matching stale *pair* (git then runs in that + * same stale worktree, so the number still belongs to the tree being committed). + * The validation exists for callers that supply id and path independently — a + * relay, the CLI, or a future in-process caller — where a mismatched pair really + * would read another workspace's meta. Keep it: it is cheap and fails closed. + * + * Meta is keyed by the raw id: the `::workspace:` suffix of folder-repo + * workspace instances is part of the key, while validation uses the stripped path. + */ +export function resolveSourceControlAiLinkedIssue( + store: Store, + args: LinkedIssueLookupArgs, + resolvedWorktreePath?: string +): number | null { + if (typeof args.worktreeId !== 'string' || !args.worktreeId) { + return null + } + if (typeof store.getWorktreeMeta !== 'function') { + return null + } + const parsed = splitWorktreeIdForFilesystem(args.worktreeId) + if (!parsed) { + return null + } + // Why: `typeof` rather than truthiness, so an empty-string repoId fails closed + // instead of silently disabling the cross-check. + if (typeof args.repoId === 'string' && parsed.repoId !== args.repoId) { + return null + } + if (!matchesRequestPath(parsed.worktreePath, args, resolvedWorktreePath)) { + return null + } + const linkedIssue = store.getWorktreeMeta(args.worktreeId)?.linkedIssue + // Why: GitHub only in v1 — no `linkedGitLabIssue` dual-read. + return isLinkedIssueNumber(linkedIssue) ? linkedIssue : null +} diff --git a/src/main/ipc/speech-ipc-admission.ts b/src/main/ipc/speech-ipc-admission.ts new file mode 100644 index 00000000000..e96989cf364 --- /dev/null +++ b/src/main/ipc/speech-ipc-admission.ts @@ -0,0 +1,152 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export const MAX_SPEECH_AUDIO_CHUNK_BYTES = 1024 * 1024 +export const MAX_SPEECH_HOTWORDS = 256 +export const MAX_SPEECH_HOTWORD_BYTES = 4 * 1024 +export const MAX_SPEECH_HOTWORDS_TOTAL_BYTES = 256 * 1024 +export const MAX_SPEECH_SESSION_ID_BYTES = 1024 +export const MAX_SPEECH_MODEL_ID_BYTES = 1024 +export const MAX_SPEECH_OPENAI_API_KEY_BYTES = 64 * 1024 +export const MAX_PENDING_DESKTOP_DICTATION_STARTS = 16 +export const MAX_ACTIVE_DESKTOP_DICTATION_LISTENERS = 16 + +const MIN_SPEECH_SAMPLE_RATE = 8_000 +const MAX_SPEECH_SAMPLE_RATE = 384_000 +const HOTWORD_LINE_SUFFIX = ' :2.0\n' + +export type DesktopDictationListener = { release: () => void } + +function requireBoundedString( + value: unknown, + name: string, + maxBytes: number, + options: { allowEmpty?: boolean } = {} +): string { + if ( + typeof value !== 'string' || + (!options.allowEmpty && value.length === 0) || + measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit + ) { + throw new Error(`Invalid ${name}`) + } + return value +} + +export function validateSpeechSessionId(value: unknown): string { + return requireBoundedString( + value === undefined ? 'desktop' : value, + 'speech session id', + MAX_SPEECH_SESSION_ID_BYTES + ) +} + +export function validateSpeechModelId(value: unknown): string { + return requireBoundedString(value, 'speech model id', MAX_SPEECH_MODEL_ID_BYTES) +} + +export function validateOpenAiSpeechApiKey(value: unknown): string { + return requireBoundedString(value, 'OpenAI speech API key', MAX_SPEECH_OPENAI_API_KEY_BYTES) +} + +export function buildSpeechHotwordsContent(value: unknown): string | undefined { + if (value === undefined) { + return undefined + } + if (!Array.isArray(value) || value.length > MAX_SPEECH_HOTWORDS) { + throw new Error('Invalid speech hotwords') + } + const lines: string[] = [] + let retainedBytes = 0 + for (const hotword of value) { + const text = requireBoundedString(hotword, 'speech hotword', MAX_SPEECH_HOTWORD_BYTES, { + allowEmpty: true + }) + const lineBytes = measureUtf8ByteLength(text).byteLength + HOTWORD_LINE_SUFFIX.length + if (lineBytes > MAX_SPEECH_HOTWORDS_TOTAL_BYTES - retainedBytes) { + throw new Error('Speech hotwords are too large') + } + retainedBytes += lineBytes + lines.push(`${text}${HOTWORD_LINE_SUFFIX}`) + } + return lines.length > 0 ? lines.join('') : undefined +} + +export function decodeSpeechAudioChunk( + value: unknown, + sampleRate: unknown +): { samples: Float32Array; sampleRate: number } { + if ( + !(value instanceof Uint8Array) || + value.byteLength === 0 || + value.byteLength > MAX_SPEECH_AUDIO_CHUNK_BYTES || + value.byteLength % Float32Array.BYTES_PER_ELEMENT !== 0 + ) { + throw new Error('Invalid speech audio chunk') + } + if ( + typeof sampleRate !== 'number' || + !Number.isFinite(sampleRate) || + sampleRate < MIN_SPEECH_SAMPLE_RATE || + sampleRate > MAX_SPEECH_SAMPLE_RATE + ) { + throw new Error('Invalid speech sample rate') + } + // Why: worker transfer moves the whole backing buffer; own only the admitted view. + const ownedBytes = new Uint8Array(value.byteLength) + ownedBytes.set(value) + return { samples: new Float32Array(ownedBytes.buffer), sampleRate } +} + +export class SpeechIpcAdmission { + private readonly pendingStarts = new Set() + private readonly activeListeners = new Map() + + claimStart(owner: string): void { + if ( + this.pendingStarts.has(owner) || + this.pendingStarts.size >= MAX_PENDING_DESKTOP_DICTATION_STARTS + ) { + throw new Error('Too many pending speech dictation starts') + } + this.pendingStarts.add(owner) + } + + releaseStart(owner: string): void { + this.pendingStarts.delete(owner) + } + + commitListener(owner: string, listener: DesktopDictationListener): void { + const previous = this.activeListeners.get(owner) + if (!previous && this.activeListeners.size >= MAX_ACTIVE_DESKTOP_DICTATION_LISTENERS) { + this.activeListeners.values().next().value?.release() + } + this.activeListeners.set(owner, listener) + previous?.release() + } + + deleteListenerIfCurrent(owner: string, listener: DesktopDictationListener): void { + if (this.activeListeners.get(owner) === listener) { + this.activeListeners.delete(owner) + } + } + + releaseListener(owner: string): void { + this.activeListeners.get(owner)?.release() + } + + reset(): void { + this.pendingStarts.clear() + for (const listener of Array.from(this.activeListeners.values())) { + listener.release() + } + this.activeListeners.clear() + } + + get pendingStartCount(): number { + return this.pendingStarts.size + } + + get activeListenerCount(): number { + return this.activeListeners.size + } +} diff --git a/src/main/ipc/speech.test.ts b/src/main/ipc/speech.test.ts index 2e84124b8e9..f3587b947e5 100644 --- a/src/main/ipc/speech.test.ts +++ b/src/main/ipc/speech.test.ts @@ -43,18 +43,46 @@ vi.mock('../speech/speech-model-deletion', () => ({ deleteLocalSpeechModel: deleteLocalSpeechModelMock })) -import { registerSpeechHandlers } from './speech' +import { + clearSpeechIpcAdmissionForTests, + getActiveDesktopDictationListenerCountForTest, + getPendingDesktopDictationStartCountForTest, + registerSpeechHandlers +} from './speech' +import { + MAX_PENDING_DESKTOP_DICTATION_STARTS, + MAX_SPEECH_AUDIO_CHUNK_BYTES, + MAX_SPEECH_HOTWORD_BYTES, + MAX_SPEECH_HOTWORDS, + MAX_SPEECH_SESSION_ID_BYTES +} from './speech-ipc-admission' type SpeechDownloadHandler = (event: { sender: { id: number } }, modelId: string) => Promise -function getHandler(channel: string): SpeechDownloadHandler { +function getHandler(channel: string): T { const call = handleMock.mock.calls.find((entry) => entry[0] === channel) if (!call) { throw new Error(`${channel} handler not registered`) } - return call[1] as SpeechDownloadHandler + return call[1] as T } +type SpeechFeedHandler = ( + event: { sender: { id: number } }, + buffer: Uint8Array, + sampleRate: number, + sessionId?: string +) => Promise + +type SpeechStartHandler = ( + event: { sender: { id: number } }, + modelId: string, + hotwords?: string[], + sessionId?: string +) => Promise + +type SpeechStopHandler = (event: { sender: { id: number } }, sessionId?: string) => Promise + describe('registerSpeechHandlers', () => { beforeEach(() => { handleMock.mockReset() @@ -62,6 +90,7 @@ describe('registerSpeechHandlers', () => { getSpeechModelManagerMock.mockReset() getSpeechSttServiceMock.mockReset() deleteLocalSpeechModelMock.mockReset() + clearSpeechIpcAdmissionForTests() }) it('clears the model download progress callback after completion', async () => { @@ -156,4 +185,151 @@ describe('registerSpeechHandlers', () => { modelId: 'model-1' }) }) + + it('rejects oversized or malformed audio chunks before reaching the STT service', async () => { + const feedAudio = vi.fn() + getSpeechSttServiceMock.mockReturnValue({ feedAudio }) + registerSpeechHandlers({} as never) + const handler = getHandler('speech:feedAudio') + + await expect( + handler( + { sender: { id: 7 } }, + new Uint8Array(MAX_SPEECH_AUDIO_CHUNK_BYTES + 4), + 48_000, + 'session' + ) + ).rejects.toThrow('Invalid speech audio chunk') + await expect( + handler({ sender: { id: 7 } }, new Uint8Array(3), 48_000, 'session') + ).rejects.toThrow('Invalid speech audio chunk') + expect(feedAudio).not.toHaveBeenCalled() + }) + + it('copies an admitted audio view into an exact-size transferable buffer', async () => { + const feedAudio = vi.fn() + getSpeechSttServiceMock.mockReturnValue({ feedAudio }) + registerSpeechHandlers({} as never) + const handler = getHandler('speech:feedAudio') + const oversizedBacking = new Uint8Array(MAX_SPEECH_AUDIO_CHUNK_BYTES + 128) + const admittedView = oversizedBacking.subarray(64, 64 + MAX_SPEECH_AUDIO_CHUNK_BYTES) + + await handler({ sender: { id: 8 } }, admittedView, 48_000, 'session') + + const samples = feedAudio.mock.calls[0]?.[0] as Float32Array + expect(samples.byteLength).toBe(MAX_SPEECH_AUDIO_CHUNK_BYTES) + expect(samples.buffer.byteLength).toBe(MAX_SPEECH_AUDIO_CHUNK_BYTES) + expect(feedAudio).toHaveBeenCalledWith(samples, 48_000, 'desktop:8:session') + }) + + it.each([ + ['count', Array.from({ length: MAX_SPEECH_HOTWORDS + 1 }, () => 'word')], + ['per-word bytes', ['😀'.repeat(Math.floor(MAX_SPEECH_HOTWORD_BYTES / 4) + 1)]], + ['aggregate bytes', Array.from({ length: 64 }, () => 'x'.repeat(MAX_SPEECH_HOTWORD_BYTES))] + ])('rejects hotword %s overflow before window or service retention', async (_label, hotwords) => { + registerSpeechHandlers({} as never) + const handler = getHandler('speech:startDictation') + + await expect(handler({ sender: { id: 9 } }, 'model-1', hotwords, 'session')).rejects.toThrow( + /hotword/i + ) + expect(fromWebContentsMock).not.toHaveBeenCalled() + expect(getSpeechSttServiceMock).not.toHaveBeenCalled() + }) + + it('rejects oversized session ids for start, feed, and stop before service work', async () => { + registerSpeechHandlers({} as never) + const oversizedSession = '😀'.repeat(Math.floor(MAX_SPEECH_SESSION_ID_BYTES / 4) + 1) + + await expect( + getHandler('speech:startDictation')( + { sender: { id: 10 } }, + 'model-1', + undefined, + oversizedSession + ) + ).rejects.toThrow('Invalid speech session id') + await expect( + getHandler('speech:feedAudio')( + { sender: { id: 10 } }, + new Uint8Array(4), + 16_000, + oversizedSession + ) + ).rejects.toThrow('Invalid speech session id') + await expect( + getHandler('speech:stopDictation')( + { sender: { id: 10 } }, + oversizedSession + ) + ).rejects.toThrow('Invalid speech session id') + expect(getSpeechSttServiceMock).not.toHaveBeenCalled() + }) + + it('caps concurrent desktop dictation starts and releases admission after settlement', async () => { + let resolveStarts = (): void => {} + const startGate = new Promise((resolve) => { + resolveStarts = resolve + }) + const startDictation = vi.fn(() => startGate) + getSpeechSttServiceMock.mockReturnValue({ startDictation, stopDictation: vi.fn() }) + fromWebContentsMock.mockImplementation((sender: { id: number }) => ({ + isDestroyed: vi.fn(() => false), + webContents: { send: vi.fn() }, + once: vi.fn(), + off: vi.fn(), + sender + })) + registerSpeechHandlers({} as never) + const handler = getHandler('speech:startDictation') + const starts = Array.from({ length: MAX_PENDING_DESKTOP_DICTATION_STARTS }, (_, index) => + handler({ sender: { id: 100 + index } }, 'model-1', undefined, 'session') + ) + + await vi.waitFor(() => + expect(getPendingDesktopDictationStartCountForTest()).toBe( + MAX_PENDING_DESKTOP_DICTATION_STARTS + ) + ) + await expect(handler({ sender: { id: 999 } }, 'model-1', undefined, 'session')).rejects.toThrow( + 'Too many pending speech dictation starts' + ) + resolveStarts() + await Promise.all(starts) + + expect(startDictation).toHaveBeenCalledTimes(MAX_PENDING_DESKTOP_DICTATION_STARTS) + expect(getPendingDesktopDictationStartCountForTest()).toBe(0) + }) + + it('replaces the retained window listener for a sequential same-owner restart', async () => { + const stopDictation = vi.fn().mockResolvedValue(undefined) + getSpeechSttServiceMock.mockReturnValue({ + startDictation: vi.fn().mockResolvedValue(undefined), + stopDictation + }) + const firstWindow = { + isDestroyed: vi.fn(() => false), + webContents: { send: vi.fn() }, + once: vi.fn(), + off: vi.fn() + } + const secondWindow = { + isDestroyed: vi.fn(() => false), + webContents: { send: vi.fn() }, + once: vi.fn(), + off: vi.fn() + } + fromWebContentsMock.mockReturnValueOnce(firstWindow).mockReturnValueOnce(secondWindow) + registerSpeechHandlers({} as never) + const start = getHandler('speech:startDictation') + + await start({ sender: { id: 200 } }, 'model-1', undefined, 'session') + await start({ sender: { id: 200 } }, 'model-1', undefined, 'session') + + expect(getActiveDesktopDictationListenerCountForTest()).toBe(1) + expect(firstWindow.off).toHaveBeenCalledWith('closed', expect.any(Function)) + await getHandler('speech:stopDictation')({ sender: { id: 200 } }, 'session') + expect(secondWindow.off).toHaveBeenCalledWith('closed', expect.any(Function)) + expect(getActiveDesktopDictationListenerCountForTest()).toBe(0) + }) }) diff --git a/src/main/ipc/speech.ts b/src/main/ipc/speech.ts index f89a2ab8b70..572493c9deb 100644 --- a/src/main/ipc/speech.ts +++ b/src/main/ipc/speech.ts @@ -11,6 +11,28 @@ import { saveOpenAiSpeechApiKey } from '../speech/openai-api-key-store' import type { Store } from '../persistence' +import { + buildSpeechHotwordsContent, + decodeSpeechAudioChunk, + SpeechIpcAdmission, + validateOpenAiSpeechApiKey, + validateSpeechModelId, + validateSpeechSessionId +} from './speech-ipc-admission' + +const speechAdmission = new SpeechIpcAdmission() + +export function clearSpeechIpcAdmissionForTests(): void { + speechAdmission.reset() +} + +export function getPendingDesktopDictationStartCountForTest(): number { + return speechAdmission.pendingStartCount +} + +export function getActiveDesktopDictationListenerCountForTest(): number { + return speechAdmission.activeListenerCount +} export function registerSpeechHandlers(store: Store): void { ipcMain.handle('speech:getCatalog', () => { @@ -26,7 +48,7 @@ export function registerSpeechHandlers(store: Store): void { }) ipcMain.handle('speech:saveOpenAiApiKey', async (_event, apiKey: string) => { - saveOpenAiSpeechApiKey(apiKey) + saveOpenAiSpeechApiKey(validateOpenAiSpeechApiKey(apiKey)) return { configured: true } }) @@ -36,6 +58,7 @@ export function registerSpeechHandlers(store: Store): void { }) ipcMain.handle('speech:downloadModel', async (event, modelId: string) => { + const validatedModelId = validateSpeechModelId(modelId) const manager = getSpeechModelManager(store) const window = BrowserWindow.fromWebContents(event.sender) if (!window) { @@ -59,22 +82,23 @@ export function registerSpeechHandlers(store: Store): void { } window.once('closed', cleanupProgressCallback) try { - await manager.downloadModel(modelId) + await manager.downloadModel(validatedModelId) } finally { cleanupProgressCallback() } }) ipcMain.handle('speech:cancelDownload', async (_event, modelId: string) => { - getSpeechModelManager(store).cancelDownload(modelId) + getSpeechModelManager(store).cancelDownload(validateSpeechModelId(modelId)) }) ipcMain.handle('speech:deleteModel', async (_event, modelId: string) => { + const validatedModelId = validateSpeechModelId(modelId) await deleteLocalSpeechModel({ store, modelManager: getSpeechModelManager(store), sttService: getSpeechSttService(store), - modelId + modelId: validatedModelId }) }) @@ -91,15 +115,21 @@ export function registerSpeechHandlers(store: Store): void { ipcMain.handle( 'speech:startDictation', async (event, modelId: string, hotwords?: string[], sessionId = 'desktop') => { + const validatedModelId = validateSpeechModelId(modelId) + const validatedSessionId = validateSpeechSessionId(sessionId) + const hotwordsContent = buildSpeechHotwordsContent(hotwords) const window = BrowserWindow.fromWebContents(event.sender) if (!window) { return } let resolvedHotwordsPath: string | undefined let windowClosed = false - const owner = getDesktopOwner(event.sender.id, sessionId) + let sessionListenerReleased = false + const owner = getDesktopOwner(event.sender.id, validatedSessionId) + speechAdmission.claimStart(owner) const cleanupOnWindowClosed = (): void => { windowClosed = true + cleanupSessionListener() void getSpeechSttService(store) .stopDictation(owner) .finally(() => { @@ -110,8 +140,14 @@ export function registerSpeechHandlers(store: Store): void { .catch(() => {}) } const cleanupSessionListener = (): void => { + if (sessionListenerReleased) { + return + } + sessionListenerReleased = true window.off('closed', cleanupOnWindowClosed) + speechAdmission.deleteListenerIfCurrent(owner, sessionListener) } + const sessionListener = { release: cleanupSessionListener } window.once('closed', cleanupOnWindowClosed) try { @@ -133,10 +169,9 @@ export function registerSpeechHandlers(store: Store): void { } } - if (hotwords && hotwords.length > 0) { - const content = `${hotwords.map((w) => `${w} :2.0`).join('\n')}\n` - const hotwordsFilePath = getHotwordsFilePath(content) - await writeFile(hotwordsFilePath, content, 'utf-8') + if (hotwordsContent) { + const hotwordsFilePath = getHotwordsFilePath(hotwordsContent) + await writeFile(hotwordsFilePath, hotwordsContent, 'utf-8') resolvedHotwordsPath = hotwordsFilePath } @@ -149,27 +184,36 @@ export function registerSpeechHandlers(store: Store): void { } await getSpeechSttService(store).startDictation( - modelId, + validatedModelId, (msg) => { if (window.isDestroyed()) { return } switch (msg.type) { case 'ready': - window.webContents.send('speech:ready', { sessionId }) + window.webContents.send('speech:ready', { sessionId: validatedSessionId }) break case 'partial': - window.webContents.send('speech:partial', { text: msg.text ?? '', sessionId }) + window.webContents.send('speech:partial', { + text: msg.text ?? '', + sessionId: validatedSessionId + }) break case 'final': - window.webContents.send('speech:final', { text: msg.text ?? '', sessionId }) + window.webContents.send('speech:final', { + text: msg.text ?? '', + sessionId: validatedSessionId + }) break case 'stopped': cleanupSessionListener() - window.webContents.send('speech:stopped', { sessionId }) + window.webContents.send('speech:stopped', { sessionId: validatedSessionId }) break case 'error': - window.webContents.send('speech:error', { error: msg.error ?? '', sessionId }) + window.webContents.send('speech:error', { + error: msg.error ?? '', + sessionId: validatedSessionId + }) void getSpeechSttService(store) .stopDictation(owner) .catch(() => undefined) @@ -180,6 +224,14 @@ export function registerSpeechHandlers(store: Store): void { resolvedHotwordsPath, owner ) + if (windowClosed || window.isDestroyed() || sessionListenerReleased) { + cleanupSessionListener() + if (resolvedHotwordsPath) { + unlink(resolvedHotwordsPath).catch(() => {}) + } + return + } + speechAdmission.commitListener(owner, sessionListener) if (resolvedHotwordsPath) { unlink(resolvedHotwordsPath).catch(() => {}) } @@ -189,25 +241,33 @@ export function registerSpeechHandlers(store: Store): void { unlink(resolvedHotwordsPath).catch(() => {}) } throw err + } finally { + speechAdmission.releaseStart(owner) } } ) ipcMain.handle( 'speech:feedAudio', - async (_event, buffer: Buffer, sampleRate: number, sessionId = 'desktop') => { + async (_event, buffer: Uint8Array, sampleRate: number, sessionId = 'desktop') => { // Why: the preload sends audio as a Buffer to avoid Float32Array data // being zeroed out during contextBridge + IPC serialization. - const samples = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) + const audio = decodeSpeechAudioChunk(buffer, sampleRate) + const validatedSessionId = validateSpeechSessionId(sessionId) getSpeechSttService(store).feedAudio( - samples, - sampleRate, - getDesktopOwner(_event.sender.id, sessionId) + audio.samples, + audio.sampleRate, + getDesktopOwner(_event.sender.id, validatedSessionId) ) } ) ipcMain.handle('speech:stopDictation', async (_event, sessionId = 'desktop') => { - await getSpeechSttService(store).stopDictation(getDesktopOwner(_event.sender.id, sessionId)) + const owner = getDesktopOwner(_event.sender.id, validateSpeechSessionId(sessionId)) + try { + await getSpeechSttService(store).stopDictation(owner) + } finally { + speechAdmission.releaseListener(owner) + } }) } diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts index 6d04e8c7b0f..64f4e81c94c 100644 --- a/src/main/ipc/ssh-browse.test.ts +++ b/src/main/ipc/ssh-browse.test.ts @@ -1,6 +1,10 @@ import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { registerSshBrowseHandler } from './ssh-browse' +import { registerSshBrowseHandler, type RemoteDirEntry } from './ssh-browse' +import { + FILESYSTEM_DIRECTORY_LIMIT_MESSAGE, + FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES +} from '../../shared/filesystem-directory-listing-limit' const { handleMock, removeHandlerMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -77,6 +81,68 @@ describe('registerSshBrowseHandler', () => { expect(channel.stderr.listenerCount('error')).toBe(0) }) + it('closes and rejects a remote listing that exceeds the shared byte limit', async () => { + const channel = Object.assign(createMockChannel(), { close: vi.fn() }) + const exec = vi.fn().mockResolvedValue(channel) + const getConnectionManager = () => ({ getConnection: () => ({ exec }) }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + await Promise.resolve() + channel.emit('data', Buffer.alloc(FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES + 1, 0x78)) + + await expect(resultPromise).rejects.toThrow(FILESYSTEM_DIRECTORY_LIMIT_MESSAGE) + expect(channel.close).toHaveBeenCalledOnce() + expect(channel.listenerCount('data')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + }) + + it('retains tens of thousands of tiny stdout chunks without quadratic copying', async () => { + const channel = createMockChannel() + const exec = vi.fn().mockResolvedValue(channel) + const getConnectionManager = () => ({ getConnection: () => ({ exec }) }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + await Promise.resolve() + channel.emit('data', Buffer.from('/home/user\n')) + for (let index = 0; index < 65_000; index += 1) { + channel.emit('data', Buffer.from(`f${String(index).padStart(5, '0')}\n`)) + } + channel.emit('exit', 0) + channel.emit('close') + + const result = (await resultPromise) as { + resolvedPath: string + entries: RemoteDirEntry[] + } + expect(result.resolvedPath).toBe('/home/user') + expect(result.entries).toHaveLength(65_000) + expect(result.entries[0]).toEqual({ name: 'f00000', isDirectory: false }) + expect(result.entries.at(-1)).toEqual({ name: 'f64999', isDirectory: false }) + }) + + it('decodes a filename whose UTF-8 bytes span stdout chunks', async () => { + const channel = createMockChannel() + const exec = vi.fn().mockResolvedValue(channel) + const getConnectionManager = () => ({ getConnection: () => ({ exec }) }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + await Promise.resolve() + const output = Buffer.from('/home/user\nemoji-🙂.txt\n') + const emojiOffset = output.indexOf(Buffer.from('🙂')) + channel.emit('data', output.subarray(0, emojiOffset + 1)) + channel.emit('data', output.subarray(emojiOffset + 1)) + channel.emit('exit', 0) + channel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: '/home/user', + entries: [{ name: 'emoji-🙂.txt', isDirectory: false }] + }) + }) + it('escapes remote browse paths before invoking command ls', async () => { const channel = createMockChannel() const exec = vi.fn().mockResolvedValue(channel) diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 964c877a557..f8539f63183 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -2,6 +2,14 @@ import { ipcMain } from 'electron' import type { SshConnectionManager } from '../ssh/ssh-connection' import type { SshExecOptions } from '../ssh/ssh-connection-utils' import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' +import { + createFilesystemDirectoryLimitState, + FILESYSTEM_DIRECTORY_LIMIT_MESSAGE, + FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES, + trackFilesystemDirectoryEntry +} from '../../shared/filesystem-directory-listing-limit' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' +import { SystemSshOutputTail } from '../ssh/system-ssh-output-tail' export type RemoteDirEntry = { name: string @@ -102,8 +110,8 @@ async function runBrowseCommand( const channel = options ? await conn.exec(command, options) : await conn.exec(command) return new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' + const stdout = new GrowingByteBuffer() + const stderr = new SystemSshOutputTail() let exitCode: number | null = null let settled = false let timeout: ReturnType | null = null @@ -155,10 +163,16 @@ async function runBrowseCommand( } const onStdoutData = (data: Buffer): void => { - stdout += data.toString() + if (data.byteLength > FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES - stdout.byteLength) { + stdout.clear() + rejectOnce(new Error(FILESYSTEM_DIRECTORY_LIMIT_MESSAGE)) + closeChannel() + return + } + stdout.append(data) } const onStderrData = (data: Buffer): void => { - stderr += data.toString() + stderr.push(data) } // `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing. const onExit = (code: number | null): void => { @@ -170,21 +184,24 @@ async function runBrowseCommand( const onClose = (): void => { // Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir. if (exitCode !== 0) { + const stderrText = stderr.toString() const msg = - stderr.trim() || + stderrText.trim() || (exitCode === null ? 'Remote listing failed (channel closed without exit status)' : `Remote listing failed (exit ${exitCode})`) rejectOnce(new RemoteBrowseError(msg, exitCode)) return } - if (stderr.trim() && !stdout.trim()) { - rejectOnce(new Error(stderr.trim())) + const stderrText = stderr.toString() + const stdoutText = stdout.toString('utf8') + if (stderrText.trim() && !stdoutText.trim()) { + rejectOnce(new Error(stderrText.trim())) return } // Why: Windows OpenSSH exec emits CRLF; split on \r?\n so a trailing \r doesn't defeat the endsWith('/') dir check or leave a stray CR in names. - const lines = stdout.trim().split(/\r?\n/) + const lines = stdoutText.trim().split(/\r?\n/) if (lines.length === 0) { rejectOnce(new Error('Empty response from remote')) return @@ -192,16 +209,24 @@ async function runBrowseCommand( const resolvedPath = lines[0] const entries: RemoteDirEntry[] = [] + const listingLimit = createFilesystemDirectoryLimitState() for (let i = 1; i < lines.length; i++) { const line = lines[i] if (!line || line === './' || line === '../') { continue } + const name = line.endsWith('/') ? line.slice(0, -1) : line + try { + trackFilesystemDirectoryEntry(listingLimit, { name }) + } catch (error) { + rejectOnce(error instanceof Error ? error : new Error(String(error))) + return + } if (line.endsWith('/')) { - entries.push({ name: line.slice(0, -1), isDirectory: true }) + entries.push({ name, isDirectory: true }) } else { - entries.push({ name: line, isDirectory: false }) + entries.push({ name, isDirectory: false }) } } diff --git a/src/main/ipc/ssh-passphrase.test.ts b/src/main/ipc/ssh-passphrase.test.ts new file mode 100644 index 00000000000..38d7b06973a --- /dev/null +++ b/src/main/ipc/ssh-passphrase.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' +import { + SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES, + SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES +} from '../../shared/ssh-retained-payload-admission' + +const electronMocks = vi.hoisted(() => { + const handlers = new Map unknown>() + return { + handlers, + ipcMain: { + removeHandler: vi.fn((channel: string) => handlers.delete(channel)), + handle: vi.fn((channel: string, handler: (event: unknown, args: unknown) => unknown) => { + handlers.set(channel, handler) + }) + } + } +}) + +vi.mock('electron', () => ({ ipcMain: electronMocks.ipcMain })) + +import { + getPendingCredentialRequestCountForTests, + registerCredentialHandler, + requestCredential, + resetPendingCredentialRequestsForTests, + SSH_CREDENTIAL_VALUE_MAX_UTF8_BYTES, + SSH_MAX_PENDING_CREDENTIAL_REQUESTS +} from './ssh-passphrase' + +function createWindow() { + return { + isDestroyed: vi.fn(() => false), + webContents: { send: vi.fn() } + } +} + +describe('SSH credential request admission', () => { + beforeEach(() => { + vi.useFakeTimers() + electronMocks.handlers.clear() + resetPendingCredentialRequestsForTests() + }) + + afterEach(() => { + resetPendingCredentialRequestsForTests() + vi.useRealTimers() + }) + + it('forwards ordinary metadata and resolves the submitted credential', async () => { + const win = createWindow() + registerCredentialHandler(() => win as never) + const result = requestCredential(() => win as never, 'ssh-a', 'password', 'example.test') + const request = win.webContents.send.mock.calls[0][1] as { requestId: string } + + electronMocks.handlers.get('ssh:submitCredential')?.( + {}, + { + requestId: request.requestId, + value: 'secret' + } + ) + + await expect(result).resolves.toBe('secret') + expect(win.webContents.send).toHaveBeenNthCalledWith(1, 'ssh:credential-request', { + requestId: request.requestId, + targetId: 'ssh-a', + kind: 'password', + detail: 'example.test' + }) + expect(win.webContents.send).toHaveBeenNthCalledWith(2, 'ssh:credential-resolved', { + requestId: request.requestId + }) + }) + + it('caps detail before sending it to the renderer', () => { + const win = createWindow() + + void requestCredential( + () => win as never, + 'ssh-a', + 'passphrase', + '🙂'.repeat(SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES) + ) + + const sent = win.webContents.send.mock.calls[0][1] as { detail: string } + expect(getUtf8ByteLength(sent.detail)).toBeLessThanOrEqual(SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES) + }) + + it('rejects an oversized submitted credential without retaining the request', async () => { + const win = createWindow() + registerCredentialHandler(() => win as never) + const result = requestCredential(() => win as never, 'ssh-a', 'password', 'example.test') + const request = win.webContents.send.mock.calls[0][1] as { requestId: string } + + electronMocks.handlers.get('ssh:submitCredential')?.( + {}, + { + requestId: request.requestId, + value: '🙂'.repeat(SSH_CREDENTIAL_VALUE_MAX_UTF8_BYTES) + } + ) + + await expect(result).resolves.toBeNull() + expect(getPendingCredentialRequestCountForTests()).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('settles a submitted credential when its resolution notification throws', async () => { + const win = createWindow() + registerCredentialHandler(() => win as never) + const result = requestCredential(() => win as never, 'ssh-a', 'password', 'example.test') + const request = win.webContents.send.mock.calls[0][1] as { requestId: string } + win.webContents.send.mockImplementation(() => { + throw new Error('renderer gone') + }) + + electronMocks.handlers.get('ssh:submitCredential')?.( + {}, + { + requestId: request.requestId, + value: 'secret' + } + ) + + await expect(result).resolves.toBe('secret') + expect(getPendingCredentialRequestCountForTests()).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('settles a timed-out credential when its resolution notification throws', async () => { + const win = createWindow() + const result = requestCredential(() => win as never, 'ssh-a', 'password', 'example.test') + win.webContents.send.mockImplementation(() => { + throw new Error('renderer gone') + }) + + await vi.runAllTimersAsync() + + await expect(result).resolves.toBeNull() + expect(getPendingCredentialRequestCountForTests()).toBe(0) + }) + + it('rejects an oversized target without allocating request state', async () => { + const win = createWindow() + const result = requestCredential( + () => win as never, + 'x'.repeat(SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + 1), + 'password', + 'host' + ) + + await expect(result).resolves.toBeNull() + expect(getPendingCredentialRequestCountForTests()).toBe(0) + expect(vi.getTimerCount()).toBe(0) + expect(win.webContents.send).not.toHaveBeenCalled() + }) + + it('fails closed at the pending-request cap without adding a timer or map row', async () => { + const win = createWindow() + const pending = Array.from({ length: SSH_MAX_PENDING_CREDENTIAL_REQUESTS }, (_, index) => + requestCredential(() => win as never, `ssh-${index}`, 'password', 'host') + ) + expect(getPendingCredentialRequestCountForTests()).toBe(SSH_MAX_PENDING_CREDENTIAL_REQUESTS) + expect(vi.getTimerCount()).toBe(SSH_MAX_PENDING_CREDENTIAL_REQUESTS) + + const overflow = requestCredential(() => win as never, 'ssh-overflow', 'password', 'host') + + await expect(overflow).resolves.toBeNull() + expect(getPendingCredentialRequestCountForTests()).toBe(SSH_MAX_PENDING_CREDENTIAL_REQUESTS) + expect(vi.getTimerCount()).toBe(SSH_MAX_PENDING_CREDENTIAL_REQUESTS) + expect(win.webContents.send).toHaveBeenCalledTimes(SSH_MAX_PENDING_CREDENTIAL_REQUESTS) + resetPendingCredentialRequestsForTests() + await expect(Promise.all(pending)).resolves.toEqual( + Array.from({ length: SSH_MAX_PENDING_CREDENTIAL_REQUESTS }, () => null) + ) + }) +}) diff --git a/src/main/ipc/ssh-passphrase.ts b/src/main/ipc/ssh-passphrase.ts index e1e21045373..8540ee8e40b 100644 --- a/src/main/ipc/ssh-passphrase.ts +++ b/src/main/ipc/ssh-passphrase.ts @@ -1,8 +1,15 @@ import { ipcMain, type BrowserWindow } from 'electron' import { randomUUID } from 'node:crypto' import type { SshCredentialKind } from '../ssh/ssh-connection-utils' +import { + isSshRetainedIdentifier, + SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES +} from '../../shared/ssh-retained-payload-admission' +import { clampUtf8TextPrefix, measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' const CREDENTIAL_TIMEOUT_MS = 120_000 +export const SSH_MAX_PENDING_CREDENTIAL_REQUESTS = 64 +export const SSH_CREDENTIAL_VALUE_MAX_UTF8_BYTES = 64 * 1024 const pendingRequests = new Map void }>() function notifyCredentialResolved( @@ -11,7 +18,11 @@ function notifyCredentialResolved( ): void { const win = getMainWindow() if (win && !win.isDestroyed()) { - win.webContents.send('ssh:credential-resolved', { requestId }) + try { + win.webContents.send('ssh:credential-resolved', { requestId }) + } catch { + // The SSH caller must still settle if its renderer disappears between checks. + } } } @@ -21,6 +32,16 @@ export function requestCredential( kind: SshCredentialKind, detail: string ): Promise { + const win = getMainWindow() + if ( + !isSshRetainedIdentifier(targetId) || + pendingRequests.size >= SSH_MAX_PENDING_CREDENTIAL_REQUESTS || + !win || + win.isDestroyed() + ) { + return Promise.resolve(null) + } + const retainedDetail = clampUtf8TextPrefix(detail, SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES) const requestId = randomUUID() return new Promise((resolve) => { const timer = setTimeout(() => { @@ -37,13 +58,16 @@ export function requestCredential( } }) - const win = getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail }) - } else { + try { + win.webContents.send('ssh:credential-request', { + requestId, + targetId, + kind, + detail: retainedDetail + }) + } catch { pendingRequests.delete(requestId) clearTimeout(timer) - notifyCredentialResolved(getMainWindow, requestId) resolve(null) } }) @@ -53,13 +77,36 @@ export function registerCredentialHandler(getMainWindow: () => BrowserWindow | n ipcMain.removeHandler('ssh:submitCredential') ipcMain.handle( 'ssh:submitCredential', - (_event, args: { requestId: string; value: string | null }) => { + (_event, args: { requestId?: unknown; value?: unknown }) => { + if (!args || typeof args !== 'object' || typeof args.requestId !== 'string') { + return + } const pending = pendingRequests.get(args.requestId) if (pending) { pendingRequests.delete(args.requestId) notifyCredentialResolved(getMainWindow, args.requestId) - pending.resolve(args.value) + const value = + args.value === null || + (typeof args.value === 'string' && + !measureUtf8ByteLength(args.value, { + stopAfterBytes: SSH_CREDENTIAL_VALUE_MAX_UTF8_BYTES + }).exceededLimit) + ? args.value + : null + pending.resolve(value) } } ) } + +export function resetPendingCredentialRequestsForTests(): void { + const pending = Array.from(pendingRequests.values()) + pendingRequests.clear() + for (const request of pending) { + request.resolve(null) + } +} + +export function getPendingCredentialRequestCountForTests(): number { + return pendingRequests.size +} diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 3780b827ef4..45abd3a7bfb 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -18,7 +18,10 @@ const { mockPortForwardManager, mockPortScannerCallbacks, mockNextConnectionManagers, - mockNextPortForwardManagers + mockNextPortForwardManagers, + routeExternalPtyDataMock, + routeExternalPtyReplayMock, + routeExternalPtyExitMock } = vi.hoisted(() => ({ handleMock: vi.fn(), powerMonitorOffMock: vi.fn(), @@ -81,7 +84,10 @@ const { }, mockPortScannerCallbacks: new Map(), mockNextConnectionManagers: [] as unknown[], - mockNextPortForwardManagers: [] as unknown[] + mockNextPortForwardManagers: [] as unknown[], + routeExternalPtyDataMock: vi.fn(), + routeExternalPtyReplayMock: vi.fn(), + routeExternalPtyExitMock: vi.fn() })) vi.mock('electron', () => ({ @@ -167,6 +173,12 @@ vi.mock('./pty', () => ({ isRendererPtyOutputPaused: vi.fn().mockReturnValue(false) })) +vi.mock('./pty-renderer-delivery-router', () => ({ + routeExternalPtyData: routeExternalPtyDataMock, + routeExternalPtyReplay: routeExternalPtyReplayMock, + routeExternalPtyExit: routeExternalPtyExitMock +})) + vi.mock('../providers/ssh-filesystem-dispatch', () => ({ registerSshFilesystemProvider: vi.fn(), unregisterSshFilesystemProvider: vi.fn(), @@ -214,7 +226,6 @@ vi.mock('../ssh/ssh-port-scanner', () => ({ })) import { getSshConnectionManager, registerSshHandlers, resetSshHandlerStateForTests } from './ssh' -import { RelayVersionMismatchError } from '../ssh/ssh-relay-version-mismatch-error' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, type SshConnectionState, @@ -226,7 +237,11 @@ import { getSshPtyProvider, getPtyIdsForConnection } from './pty' -import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' +import { + MAX_TRACKED_SSH_CONNECTION_GENERATIONS, + assertSshMutationExpectation, + retainSshConnectionGeneration +} from '../ssh/ssh-connection-generation' describe('SSH IPC handlers', () => { const handlers = new Map unknown>() @@ -335,6 +350,9 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onReplay.mockReset() mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) mockPtyProvider.shutdown.mockReset() + routeExternalPtyDataMock.mockReset() + routeExternalPtyReplayMock.mockReset() + routeExternalPtyExitMock.mockReset() mockPortForwardManager.addForward.mockReset() mockPortForwardManager.updateForward.mockReset() mockPortForwardManager.removeForward.mockReset() @@ -482,6 +500,32 @@ describe('SSH IPC handlers', () => { ) }) + it('rejects the 4,097th retained SSH target before starting connection work', async () => { + const target: SshTarget = { + id: 'ssh-overflow', + label: 'Overflow', + host: 'example.com', + port: 22, + username: 'deploy' + } + const leases = Array.from({ length: MAX_TRACKED_SSH_CONNECTION_GENERATIONS }, (_, index) => + retainSshConnectionGeneration(`ssh-cap-${index}`) + ) + mockSshStore.getTarget.mockReturnValue(target) + + try { + await expect(handlers.get('ssh:connect')!(null, { targetId: target.id })).rejects.toThrow( + 'SSH connection generation target capacity exhausted' + ) + expect(mockConnectionManager.connect).not.toHaveBeenCalled() + expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() + } finally { + for (const lease of leases) { + lease.release() + } + } + }) + it('ssh:connect calls connection manager', async () => { const target: SshTarget = { id: 'ssh-1', @@ -685,154 +729,6 @@ describe('SSH IPC handlers', () => { expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 2)).not.toThrow() }) - // Why: reproduces the "Infinite reconnect bug" — when the raw SSH transport - // connects but relay deploy fails permanently (dev build missing the platform - // relay package), doConnect must not leak the transport's premature 'connected' - // to the renderer. The renderer treats 'connected' as "session fully up" and - // remounts SSH panes (-> window.api.ssh.connect); a premature 'connected' on - // every failing attempt drives an unbounded reconnect loop. - it('does not broadcast a premature connected when relay deploy fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - // Why: mirror the real SshConnection — connect() drives the raw transport to - // 'connected' via onStateChange BEFORE the relay session establishes. The await - // yields a microtask so this lands after connectTarget records connectInFlight, - // matching the real ssh2 'ready' event (which fires async, post connect() call). - mockConnectionManager.connect.mockImplementation(async () => { - await Promise.resolve() - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connecting', - error: null, - reconnectAttempt: 0 - }) - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - supportsFolderDownload: true - }) - return conn - }) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockDeployAndLaunchRelay - .mockReset() - .mockRejectedValue( - new Error( - 'Relay package for linux-x64 not found locally. ' + - 'This may be a packaging issue — try reinstalling Orca.' - ) - ) - - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'not found locally' - ) - - // Main performs exactly one connect + one disconnect per IPC (no main-side loop). - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - - // The renderer must never see 'connected' for a connect whose relay never - // became ready — doConnect broadcasts the authoritative 'connected' only after - // establish() succeeds, which it does not here. - const connectedBroadcasts = mockWindow.webContents.send.mock.calls.filter( - ([channel, payload]) => - channel === 'ssh:state-changed' && - (payload as { state?: SshConnectionState }).state?.status === 'connected' - ) - expect(connectedBroadcasts).toEqual([]) - }) - - // Why: guards the fix's scope. A relay version mismatch during a relay reconnect - // strands the session 'idle' in activeSessions (only doConnect deletes it). A later - // transport blip then delivers a raw 'connected' with NO connect in flight — the - // 'deploying-relay' hold must NOT fire there (it would wedge the UI on an eternal - // spinner with every reconnect/reset control disabled). The hold is gated to live - // connects via connectInFlight. - it('does not hold a stray connected as deploying-relay when no connect is in flight', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - // Establish a ready relay session, then lose the relay and fail the reconnect with - // a version mismatch so the session is left stranded 'idle' in activeSessions. - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockDeployAndLaunchRelay - .mockReset() - .mockRejectedValue(new RelayVersionMismatchError('2.0.0', '1.0.0')) - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - - // The terminal relay error is surfaced; the session is now stranded 'idle'. - expect( - (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).status - ).toBe('error') - - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - mockWindow.webContents.send.mockClear() - // A transport blip on the still-live SSH socket auto-recovers to 'connected' with - // no ssh:connect in flight (connectInFlight is empty). - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'reconnecting', - error: null, - reconnectAttempt: 0 - }) - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - // The stray 'connected' is forwarded as-is — never wedged at 'deploying-relay'. - const stateChanges = mockWindow.webContents.send.mock.calls.filter( - ([channel]) => channel === 'ssh:state-changed' - ) - const lastStateChange = stateChanges.at(-1) - expect(lastStateChange).toBeDefined() - expect((lastStateChange![1] as { state: SshConnectionState }).state.status).toBe('connected') - const heldAsDeploying = stateChanges.some( - ([, payload]) => - (payload as { state?: SshConnectionState }).state?.status === 'deploying-relay' - ) - expect(heldAsDeploying).toBe(false) - } finally { - vi.useRealTimers() - } - }) - it('rebuilds instead of reusing a ready session while relay loss is pending', async () => { vi.useFakeTimers() const target: SshTarget = { @@ -997,7 +893,7 @@ describe('SSH IPC handlers', () => { } }) - it('forwards remote PTY events into the runtime', async () => { + it('forwards remote PTY events through shared renderer delivery', async () => { const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() @@ -1030,13 +926,12 @@ describe('SSH IPC handlers', () => { onData?.({ id: 'remote-pty', data: 'hello' }) onExit?.({ id: 'remote-pty', code: 7 }) - expect(runtime.onPtyData).toHaveBeenCalledWith( - 'remote-pty', - 'hello', - expect.any(Number), - 'hello'.length, - undefined - ) + expect(routeExternalPtyDataMock).toHaveBeenCalledWith({ + id: 'remote-pty', + data: 'hello' + }) + expect(routeExternalPtyExitMock).toHaveBeenCalledWith({ id: 'remote-pty', code: 7 }) + expect(runtime.onPtyData).not.toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7, undefined) }) @@ -1399,11 +1294,11 @@ describe('SSH IPC handlers', () => { connectionGeneration: 1 } }) - expect(secondWindow.webContents.send).toHaveBeenCalledWith( - 'pty:data', - expect.objectContaining({ id: 'remote-pty', data: 'hello' }) - ) - expect(secondWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + expect(routeExternalPtyDataMock).toHaveBeenCalledWith({ + id: 'remote-pty', + data: 'hello' + }) + expect(routeExternalPtyExitMock).toHaveBeenCalledWith({ id: 'remote-pty', code: 9 }) @@ -1411,13 +1306,7 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })]) }) - expect(secondRuntime.onPtyData).toHaveBeenCalledWith( - 'remote-pty', - 'hello', - expect.any(Number), - 'hello'.length, - undefined - ) + expect(secondRuntime.onPtyData).not.toHaveBeenCalled() expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9, undefined) expect(firstRuntime.onPtyData).not.toHaveBeenCalled() expect(firstRuntime.onPtyExit).not.toHaveBeenCalled() diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 287507985e4..d088aff842b 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -18,6 +18,7 @@ import type { } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' +import { clampSshConnectionError } from '../../shared/ssh-retained-payload-admission' import { isAuthError } from '../ssh/ssh-connection-utils' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' @@ -42,7 +43,9 @@ import { advanceSshConnectionGeneration, getSshConnectionGeneration, initializeSshConnectionGenerationSession, - resetSshConnectionGenerations + resetSshConnectionGenerations, + retainSshConnectionGeneration, + type SshConnectionGenerationLease } from '../ssh/ssh-connection-generation' let sshStore: SshConnectionStore | null = null @@ -135,6 +138,24 @@ export async function removeRegisteredSshTarget(targetId: string): Promise // One session per SSH target owns the whole relay lifecycle (mux, providers, abort controller, state machine). const activeSessions = new Map() +const activeSessionGenerationLeases = new Map() + +function retainActiveSessionGeneration(targetId: string): boolean { + if (activeSessionGenerationLeases.has(targetId)) { + return false + } + activeSessionGenerationLeases.set(targetId, retainSshConnectionGeneration(targetId)) + return true +} + +function releaseActiveSessionGeneration(targetId: string): void { + const lease = activeSessionGenerationLeases.get(targetId) + if (!lease) { + return + } + activeSessionGenerationLeases.delete(targetId) + lease.release() +} export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null { if (isRuntimeOwnedSshTargetId(targetId)) { @@ -173,6 +194,7 @@ async function teardownActiveSshSession( await portForwardManager?.removeAllForwards(targetId) teardown(session) activeSessions.delete(targetId) + releaseActiveSessionGeneration(targetId) clearRelayLostBackoff(targetId) clearRelayStateOverride(targetId) } @@ -184,7 +206,6 @@ function relayGracePeriodForTarget(target: SshTarget | null | undefined): number // Why: tabs must share one connect, while a disconnect must invalidate that // attempt so its late continuation cannot clobber a replacement. type ConnectAttempt = { - generation: number promise: Promise } @@ -263,6 +284,7 @@ function withSshRemotePlatform(targetId: string, state: SshConnectionState): Ssh const remotePlatform = activeSessions.get(targetId)?.getHostPlatform()?.os return { ...state, + error: clampSshConnectionError(state.error), connectionGeneration: currentConnectGeneration(targetId), ...(remotePlatform ? { remotePlatform } : {}) } @@ -558,29 +580,6 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { } else if (relayReconnectAlreadyInFlight) { // Why: duplicate connected notifications belong to the same socket generation and must not expose providers before relay recovery finishes. return - } else if ( - state.status === 'connected' && - session !== undefined && - sessionState !== 'ready' && - !completedTransportReconnect && - connectInFlight.has(targetId) - ) { - // Why: the raw SSH transport reaches 'connected' before the relay session establishes during an - // explicit connect. Forwarding it makes the renderer treat the host as fully up — it remounts - // SSH panes (-> window.api.ssh.connect) and fires connected-gated data reads before any provider - // exists. On a permanent relay-deploy failure that premature 'connected' drives an unbounded - // reconnect loop. Hold it at 'deploying-relay'; the in-flight doConnect broadcasts the - // authoritative 'connected' directly (bypassing this callback) after establish() succeeds, or a - // terminal state on failure. The connectInFlight gate keeps this scoped to a live connect, so a - // stray raw 'connected' with no follow-up (e.g. a transport blip on a session left 'idle' by a - // relay version mismatch) is never wedged at 'deploying-relay'. - clearRelayStateOverride(targetId) - broadcastSshState(getCurrentMainWindow, targetId, { - targetId, - status: 'deploying-relay', - error: state.error, - reconnectAttempt: state.reconnectAttempt - }) } else { clearRelayStateOverride(targetId) broadcastSshState(getCurrentMainWindow, targetId, state) @@ -827,31 +826,41 @@ export function registerSshHandlers( appendFileSync(e2eProbePath, `${JSON.stringify(targetId)}\n`) throw new Error('e2e_forbidden_local_ssh_connect') } - const observedGeneration = currentConnectGeneration(targetId) - const reset = resetRelayInFlight.get(targetId) - if (reset) { - await reset + const target = sshStore!.getTarget(targetId) + if (!target) { + throw new Error(`SSH target "${targetId}" not found`) } - - // Why: serialize concurrent ssh:connect for the same target; interleaved connects otherwise leak the first session. - const existing = connectInFlight.get(targetId) - if (existing) { - return existing.promise - } - if (currentConnectGeneration(targetId) !== observedGeneration) { - throw connectCancelledError() - } - - pendingTransportReconnects.delete(targetId) - const promise = doConnect(targetId) - const attempt = { generation: currentConnectGeneration(targetId), promise } - connectInFlight.set(targetId, attempt) + const generationLease = retainSshConnectionGeneration(targetId) + let observedGeneration = currentConnectGeneration(targetId) try { - return await promise - } finally { - if (connectInFlight.get(targetId) === attempt) { - connectInFlight.delete(targetId) + const reset = resetRelayInFlight.get(targetId) + if (reset) { + await reset + observedGeneration = currentConnectGeneration(targetId) } + + // Why: serialize concurrent ssh:connect for the same target; interleaved connects otherwise leak the first session. + const existing = connectInFlight.get(targetId) + if (existing) { + return existing.promise + } + if (currentConnectGeneration(targetId) !== observedGeneration) { + throw connectCancelledError() + } + + pendingTransportReconnects.delete(targetId) + const promise = doConnect(targetId) + const attempt = { promise } + connectInFlight.set(targetId, attempt) + try { + return await promise + } finally { + if (connectInFlight.get(targetId) === attempt) { + connectInFlight.delete(targetId) + } + } + } finally { + generationLease.release() } } @@ -885,7 +894,14 @@ export function registerSshHandlers( return getPublicSshState(targetId)! } - const generation = advanceSshConnectionGeneration(targetId) + const createdGenerationOwnership = retainActiveSessionGeneration(targetId) + const generation = createdGenerationOwnership + ? currentConnectGeneration(targetId) + : advanceSshConnectionGeneration(targetId) + if (generation === null) { + releaseActiveSessionGeneration(targetId) + throw connectCancelledError() + } clearRelayStateOverride(targetId) let conn // Why: tear down any existing session first to avoid leaking its multiplexer, providers, and timers (double-connect / reconnect-after-error). @@ -930,6 +946,7 @@ export function registerSshHandlers( // Why: clear this failed connect's flag so a later non-prompting connect isn't deferred. credentialRequestedForTarget.delete(targetId) activeSessions.delete(targetId) + releaseActiveSessionGeneration(targetId) clearRelayLostBackoff(targetId) clearRelayStateOverride(targetId) broadcastSshState(getCurrentMainWindow, targetId, { @@ -968,6 +985,7 @@ export function registerSshHandlers( throw connectCancelledError() } activeSessions.delete(targetId) + releaseActiveSessionGeneration(targetId) clearRelayLostBackoff(targetId) await connectionManager!.disconnect(targetId) throw err @@ -1043,6 +1061,7 @@ export function registerSshHandlers( await portForwardManager!.removeAllForwards(args.targetId) session.dispose() activeSessions.delete(args.targetId) + releaseActiveSessionGeneration(args.targetId) clearRelayLostBackoff(args.targetId) clearRelayStateOverride(args.targetId) } @@ -1050,46 +1069,53 @@ export function registerSshHandlers( }) async function doResetRelay(targetId: string, target: SshTarget): Promise { - const inFlightConnect = connectInFlight.get(targetId) - if (inFlightConnect) { - try { - // Why: resetting activeSessions mid-deploy would dispose the session doConnect will use. - await inFlightConnect.promise - } catch { - // The reset can still recover a stale remote relay after a failed connect. - } - } - - const session = activeSessions.get(targetId) - if (session) { - await portForwardManager!.removeAllForwards(targetId) - // Why: detach() not dispose() — reset has its own stale-lease semantics below that dispose()'s clean-termination recording would hide. - session.detach() - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) - } - - const existingConn = connectionManager!.getConnection(targetId) - const conn = existingConn ?? (await connectionManager!.connect(target)) + const generationLease = retainSshConnectionGeneration(targetId) try { - await forceStopRelayForTarget(conn, targetId) - } finally { - const ptyIds = new Set(getPtyIdsForConnection(targetId)) - for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { - if (lease.state !== 'terminated' && lease.state !== 'expired') { - ptyIds.add(lease.ptyId) - persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + const inFlightConnect = connectInFlight.get(targetId) + if (inFlightConnect) { + try { + // Why: resetting activeSessions mid-deploy would dispose the session doConnect will use. + await inFlightConnect.promise + } catch { + // The reset can still recover a stale remote relay after a failed connect. } } - // Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM. - for (const ptyId of ptyIds) { - const appPtyId = toAppSshPtyId(targetId, ptyId) - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) + advanceSshConnectionGeneration(targetId) + + const session = activeSessions.get(targetId) + if (session) { + await portForwardManager!.removeAllForwards(targetId) + // Why: detach() not dispose() — reset has its own stale-lease semantics below that dispose()'s clean-termination recording would hide. + session.detach() + activeSessions.delete(targetId) + releaseActiveSessionGeneration(targetId) + clearRelayLostBackoff(targetId) } - // Why: reset's connect() may trip onCredentialRequest; clear so a later non-prompting doConnect doesn't persist lastRequiredPassphrase=true. - credentialRequestedForTarget.delete(targetId) - await connectionManager!.disconnect(targetId) + + const existingConn = connectionManager!.getConnection(targetId) + const conn = existingConn ?? (await connectionManager!.connect(target)) + try { + await forceStopRelayForTarget(conn, targetId) + } finally { + const ptyIds = new Set(getPtyIdsForConnection(targetId)) + for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { + if (lease.state !== 'terminated' && lease.state !== 'expired') { + ptyIds.add(lease.ptyId) + persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + } + } + // Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM. + for (const ptyId of ptyIds) { + const appPtyId = toAppSshPtyId(targetId, ptyId) + clearProviderPtyState(appPtyId) + deletePtyOwnership(appPtyId) + } + // Why: reset's connect() may trip onCredentialRequest; clear so a later non-prompting doConnect doesn't persist lastRequiredPassphrase=true. + credentialRequestedForTarget.delete(targetId) + await connectionManager!.disconnect(targetId) + } + } finally { + generationLease.release() } } @@ -1162,7 +1188,9 @@ export function registerSshHandlers( } testingTargets.add(args.targetId) + let generationLease: SshConnectionGenerationLease | null = null try { + generationLease = retainSshConnectionGeneration(args.targetId) const conn = await connectionManager!.connect(target) const state = conn.getState() await connectionManager!.disconnect(args.targetId) @@ -1173,6 +1201,7 @@ export function registerSshHandlers( error: err instanceof Error ? err.message : String(err) } } finally { + generationLease?.release() testingTargets.delete(args.targetId) // Why: clear so a test's credential prompt doesn't leave lastRequiredPassphrase=true and defer this target at startup. credentialRequestedForTarget.delete(args.targetId) @@ -1294,6 +1323,10 @@ export async function resetSshHandlerStateForTests(): Promise { session.dispose() } activeSessions.clear() + for (const lease of activeSessionGenerationLeases.values()) { + lease.release() + } + activeSessionGenerationLeases.clear() for (const targetId of relayLostBackoff.keys()) { clearRelayLostBackoff(targetId) } diff --git a/src/main/ipc/terminal-preview-output-stream.ts b/src/main/ipc/terminal-preview-output-stream.ts index af14424cbe2..03382b40f3a 100644 --- a/src/main/ipc/terminal-preview-output-stream.ts +++ b/src/main/ipc/terminal-preview-output-stream.ts @@ -1,4 +1,5 @@ import type { WebContents } from 'electron' +import { appendCompactedStringChunk } from '../../shared/string-chunk-compaction' import { iterateTerminalInputChunks } from '../../shared/terminal-input' import type { TerminalPreviewDataPayload } from '../../shared/terminal-preview' @@ -7,6 +8,7 @@ export const TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES = 64 * 1024 const OUTPUT_IN_FLIGHT_MAX_BYTES = 512 * 1024 const OUTPUT_PENDING_MAX_BYTES = 256 * 1024 const INITIAL_PENDING_MAX_BYTES = 256 * 1024 +export const TERMINAL_PREVIEW_PENDING_MAX_RECORDS = 4_096 export type TerminalPreviewOutputMeta = { seq?: number @@ -81,7 +83,7 @@ export class TerminalPreviewOutputStream { } append(data: string, meta?: TerminalPreviewOutputMeta): void { - if (this.isDisposed || this.awaitingReconnect || this.resyncPending) { + if (data.length === 0 || this.isDisposed || this.awaitingReconnect || this.resyncPending) { return } if (this.bufferingSnapshot) { @@ -205,7 +207,10 @@ export class TerminalPreviewOutputStream { } this.pendingBatches.push({ data, bytes }) this.pendingBatchBytes += bytes - if (this.pendingBatchBytes > OUTPUT_PENDING_MAX_BYTES) { + if ( + this.pendingBatchBytes > OUTPUT_PENDING_MAX_BYTES || + this.pendingBatches.length > TERMINAL_PREVIEW_PENDING_MAX_RECORDS + ) { // Why: a stuck renderer heals from a fresh authoritative snapshot instead of retaining output without bound. this.pendingBatches = [] this.pendingBatchBytes = 0 @@ -237,7 +242,7 @@ export class TerminalPreviewOutputStream { ) { this.flushBatch() } - this.batchChunks.push(chunk) + appendCompactedStringChunk(this.batchChunks, chunk) this.batchBytes += bytes if (this.batchBytes >= TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES) { this.flushBatch() @@ -249,11 +254,18 @@ export class TerminalPreviewOutputStream { } private appendInitial(data: string, meta?: TerminalPreviewOutputMeta): void { + if (this.initialPendingOverflowed) { + return + } const bytes = Buffer.byteLength(data, 'utf8') this.initialPending.push({ data, bytes, meta }) this.initialPendingBytes += bytes - while (this.initialPendingBytes > INITIAL_PENDING_MAX_BYTES && this.initialPending.length > 0) { - this.initialPendingBytes -= this.initialPending.shift()!.bytes + if ( + this.initialPendingBytes > INITIAL_PENDING_MAX_BYTES || + this.initialPending.length > TERMINAL_PREVIEW_PENDING_MAX_RECORDS + ) { + this.initialPending = [] + this.initialPendingBytes = 0 this.initialPendingOverflowed = true } } diff --git a/src/main/ipc/terminal-preview.test.ts b/src/main/ipc/terminal-preview.test.ts index a01fd7c70eb..396b1d93f7a 100644 --- a/src/main/ipc/terminal-preview.test.ts +++ b/src/main/ipc/terminal-preview.test.ts @@ -1,26 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handlers, ipcMainMock, isDashboardPopoutRendererMock, isTrustedUIRendererMock } = - vi.hoisted(() => { - const map = new Map unknown>() - return { - handlers: map, - ipcMainMock: { - removeHandler: vi.fn(), - handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn) - }, - isDashboardPopoutRendererMock: vi.fn(() => true), - isTrustedUIRendererMock: vi.fn(() => false) - } - }) +const { handlers, ipcMainMock, isDashboardPopoutRendererMock } = vi.hoisted(() => { + const map = new Map unknown>() + return { + handlers: map, + ipcMainMock: { + removeHandler: vi.fn(), + handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn) + }, + isDashboardPopoutRendererMock: vi.fn(() => true) + } +}) vi.mock('electron', () => ({ ipcMain: ipcMainMock })) vi.mock('../window/dashboard-popout-window', () => ({ isDashboardPopoutRenderer: isDashboardPopoutRendererMock })) -vi.mock('./ui', () => ({ - isTrustedUIRenderer: isTrustedUIRendererMock -})) import { registerTerminalPreviewHandlers } from './terminal-preview' @@ -87,7 +82,6 @@ describe('registerTerminalPreviewHandlers', () => { beforeEach(() => { handlers.clear() isDashboardPopoutRendererMock.mockReturnValue(true) - isTrustedUIRendererMock.mockReturnValue(false) }) afterEach(() => { vi.clearAllMocks() @@ -287,27 +281,6 @@ describe('registerTerminalPreviewHandlers', () => { expect(runtime.writeTerminalPreviewInput).not.toHaveBeenCalled() }) - // The in-window dashboard overlay hosts the preview dialog from the main - // renderer, which is trusted but is not the popout window. - it('admits the trusted main renderer when it is not the popout', async () => { - const runtime = makeRuntime() - registerTerminalPreviewHandlers(runtime as never) - const sender = makeSender() - isDashboardPopoutRendererMock.mockReturnValue(false) - isTrustedUIRendererMock.mockReturnValue(true) - - await expect( - handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) - ).resolves.toEqual({ - snapshot: { data: 'screen', cols: 80, rows: 20, seq: 5 }, - replay: [] - }) - await expect( - handlers.get('terminalPreview:input')!(eventFor(sender), { ptyId: 'p1', data: 'x' }) - ).resolves.toBe(true) - expect(runtime.writeTerminalPreviewInput).toHaveBeenCalledWith('p1', 'x') - }) - it('pushes a resync only when the PTY grid dimensions change', async () => { const runtime = makeRuntime() registerTerminalPreviewHandlers(runtime as never) diff --git a/src/main/ipc/terminal-preview.ts b/src/main/ipc/terminal-preview.ts index 8aa14479109..39e75390737 100644 --- a/src/main/ipc/terminal-preview.ts +++ b/src/main/ipc/terminal-preview.ts @@ -5,24 +5,38 @@ import type { } from '../../shared/terminal-preview' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { isDashboardPopoutRenderer } from '../window/dashboard-popout-window' -import { isTrustedUIRenderer } from './ui' import { TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES, TerminalPreviewOutputStream } from './terminal-preview-output-stream' const PREVIEW_ID_MAX_LENGTH = 4096 +export const TERMINAL_PREVIEW_MAX_ENTRIES_PER_CONTENTS = 64 +export const TERMINAL_PREVIEW_MAX_ENTRIES_TOTAL = 256 function isValidPtyId(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.length <= PREVIEW_ID_MAX_LENGTH } -// Why: the preview dialog has two hosts — the pop-out window and the main -// renderer's in-window overlay. The trusted UI renderer already has full PTY -// access through the regular terminal channels, so admitting it adds no reach. -function isTerminalPreviewRenderer(sender: WebContents): boolean { - return isDashboardPopoutRenderer(sender) || isTrustedUIRenderer(sender) +function canRetainPreviewEntry( + registry: Map>, + contentsId: number, + ptyId: string +): boolean { + const retained = registry.get(contentsId) + if (retained?.has(ptyId)) { + return true + } + if ((retained?.size ?? 0) >= TERMINAL_PREVIEW_MAX_ENTRIES_PER_CONTENTS) { + return false + } + let total = 0 + for (const perContents of registry.values()) { + total += perContents.size + } + return total < TERMINAL_PREVIEW_MAX_ENTRIES_TOTAL } + /** Pop-out terminal transport with an atomic snapshot/live boundary. */ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): void { ipcMain.removeHandler('terminalPreview:connect') @@ -34,7 +48,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo const subscriptionsByContents = new Map>() // Why: the preview dialog claims the PTY grid through the remote-desktop // viewer registry so the main-window pane parks and later reclaims its own - // geometry. Claims are tracked per viewer webContents so an explicit + // geometry. Claims are tracked per popout webContents so an explicit // unsubscribe or a destroyed window always releases the size floor. const fitClaimsByContents = new Map>() @@ -90,11 +104,14 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo event, args: { ptyId?: unknown; opts?: { scrollbackRows?: unknown } } ): Promise => { - if (!isTerminalPreviewRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { + if (!isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { return { snapshot: null, replay: [] } } const ptyId = args.ptyId const perPty = subscriptionsFor(event.sender) + if (!canRetainPreviewEntry(subscriptionsByContents, event.sender.id, ptyId)) { + return { snapshot: null, replay: [] } + } perPty.get(ptyId)?.dispose() const subscription = new TerminalPreviewOutputStream( @@ -166,7 +183,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo 'terminalPreview:input', (event, args: { ptyId?: unknown; data?: unknown }): Promise => { if ( - !isTerminalPreviewRenderer(event.sender) || + !isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId) || typeof args.data !== 'string' ) { @@ -180,7 +197,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo 'terminalPreview:ack', (event, args: { ptyId?: unknown; bytes?: unknown }): void => { if ( - !isTerminalPreviewRenderer(event.sender) || + !isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId) || typeof args.bytes !== 'number' || !Number.isFinite(args.bytes) || @@ -204,7 +221,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo args: { ptyId?: unknown; cols?: unknown; rows?: unknown } ): Promise<{ cols: number; rows: number } | null> => { if ( - !isTerminalPreviewRenderer(event.sender) || + !isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId) || typeof args.cols !== 'number' || typeof args.rows !== 'number' || @@ -217,6 +234,9 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo // Why: guarantees the destroyed hook exists even if this claim outlives // the current output stream across a resync reconnect. subscriptionsFor(event.sender) + if (!canRetainPreviewEntry(fitClaimsByContents, event.sender.id, ptyId)) { + return null + } let claimed = fitClaimsByContents.get(event.sender.id) if (!claimed) { claimed = new Map() @@ -251,7 +271,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo ) ipcMain.handle('terminalPreview:unsubscribe', (event, args: { ptyId?: unknown }): void => { - if (!isTerminalPreviewRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { + if (!isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { return } subscriptionsByContents.get(event.sender.id)?.get(args.ptyId)?.dispose() diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts index 9026b4464d9..ddef803ee6d 100644 --- a/src/main/ipc/workspace-cleanup-activity.ts +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -1,8 +1,10 @@ -import { lstat, readFile } from 'node:fs/promises' +import { lstat } from 'node:fs/promises' import path from 'node:path' import type { Repo, Worktree } from '../../shared/types' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { MAX_WORKTREE_GIT_POINTER_BYTES } from '../worktree-orphan-gitdir-proof' type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> type ReadTextFile = (targetPath: string) => Promise @@ -42,7 +44,9 @@ async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> { } async function readLocalTextFile(targetPath: string): Promise { - return readFile(targetPath, 'utf8') + return ( + await readNodeFileWithinLimit(targetPath, MAX_WORKTREE_GIT_POINTER_BYTES) + ).buffer.toString('utf8') } async function resolveWorkspaceCleanupActivityAt( diff --git a/src/main/ipc/worktree-apfs-clone.ts b/src/main/ipc/worktree-apfs-clone.ts index 2d3b13573ab..841aa323fb4 100644 --- a/src/main/ipc/worktree-apfs-clone.ts +++ b/src/main/ipc/worktree-apfs-clone.ts @@ -95,27 +95,54 @@ async function getCachedDarwinFilesystemInfo( return pending } +async function isSameApfsVolume( + source: string, + targetDirectory: string, + deps: ApfsCloneDeps, + cache: DarwinFilesystemCache +): Promise { + const [sourceInfo, targetInfo] = await Promise.all([ + getCachedDarwinFilesystemInfo(source, deps, cache), + getCachedDarwinFilesystemInfo(targetDirectory, deps, cache) + ]) + return ( + sourceInfo.device === targetInfo.device && + sourceInfo.filesystemName === 'APFS' && + targetInfo.filesystemName === 'APFS' + ) +} + async function assertSameApfsVolume( source: string, target: string, deps: ApfsCloneDeps, cache: DarwinFilesystemCache ): Promise { - const [sourceInfo, targetInfo] = await Promise.all([ - getCachedDarwinFilesystemInfo(source, deps, cache), - getCachedDarwinFilesystemInfo(dirname(target), deps, cache) - ]) - if ( - sourceInfo.device !== targetInfo.device || - sourceInfo.filesystemName !== 'APFS' || - targetInfo.filesystemName !== 'APFS' - ) { + if (!(await isSameApfsVolume(source, dirname(target), deps, cache))) { throw new ApfsCloneUnavailableError( 'APFS clone-copy requires source and target on the same APFS volume' ) } } +/** Whether copying `source` into `targetDirectory` would take the clonefile + * path. Pure probe: it reuses the cached df+diskutil pair the clone itself + * runs and writes nothing, so a caller can size the work before any bytes + * land. A failed probe answers "no", matching the clone's own fallback to a + * real copy. */ +export async function canCloneWithApfs( + source: string, + targetDirectory: string, + deps: ApfsCloneDeps = defaultApfsCloneDeps, + filesystemCache: DarwinFilesystemCache = new Map() +): Promise { + try { + return await isSameApfsVolume(source, targetDirectory, deps, filesystemCache) + } catch { + return false + } +} + async function cloneFileWithApfs( source: string, target: string, diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 31a068146c8..8ec685fbff6 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -1,12 +1,17 @@ -import { readdir, stat } from 'node:fs/promises' import { join } from 'node:path' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { WorktreeBaseRepoWatchConfig, WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import { startGitCommonWatch } from './worktree-git-common-watch' +import type { WorktreePollingScanLimits } from './worktree-polling-scan-budget' +import { + collectWorktreeBaseDirectorySignatures, + hasWorktreeGitMarker, + takeWorktreeBaseDirectorySnapshot, + type WorktreeBaseDirectorySnapshot +} from './worktree-base-directory-snapshot' export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string } @@ -61,6 +66,7 @@ export type WorktreeBasePollerOptions = { visibility?: WorktreePollerWindowVisibility /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void + scanLimits?: Partial } // Why: these targets used to be recursive FSEvents subscriptions spanning the @@ -84,93 +90,10 @@ export const WORKTREE_BASE_BACKSTOP_TICKS = 15 // backstop scan cover the pathological case. const PENDING_MARKER_MAX_TICKS = 300 -function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { - return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` -} - -async function dirSignature(path: string): Promise { - try { - return statSignature(await stat(path)) - } catch { - return 'missing' - } -} - -async function hasGitMarker(dir: string): Promise { - try { - await stat(join(dir, '.git')) - return true - } catch { - return false - } -} - -type BaseSnapshot = { - // worktree-candidate dir → whether its `.git` completion marker exists - markers: Map - // dirs whose listing determines the candidate set: the root plus any - // nested repo containers. Their stat signatures gate the next full scan. - gateDirs: string[] -} - -// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested -// repo's container, mirroring what worktree-base-directory-event-filter -// matches: `/.git` completion markers and `` deletions. -async function snapshotBase( - rootPath: string, - repos: ReadonlyMap -): Promise { - const markers = new Map() - const gateDirs = [rootPath] - const configs = [...repos.values()] - const includeFlat = configs.some((config) => !config.nestWorkspaces) - const nestedRepoNames = new Set( - configs - .filter((config) => config.nestWorkspaces) - .map((config) => normalizeRuntimePathForComparison(config.repoName)) - ) - - let rootEntries - try { - rootEntries = await readdir(rootPath, { withFileTypes: true }) - } catch { - // Root vanished: an empty snapshot diffs into delete events for every - // previously-known worktree dir, matching the old watcher's error path. - return { markers, gateDirs } - } - - const candidates: string[] = [] - for (const entry of rootEntries) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) { - continue - } - const entryPath = join(rootPath, entry.name) - if (includeFlat) { - candidates.push(entryPath) - } - if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { - gateDirs.push(entryPath) - let subEntries - try { - subEntries = await readdir(entryPath, { withFileTypes: true }) - } catch { - subEntries = [] - } - for (const sub of subEntries) { - if (sub.isDirectory() || sub.isSymbolicLink()) { - candidates.push(join(entryPath, sub.name)) - } - } - } - } - - for (const dir of candidates) { - markers.set(dir, await hasGitMarker(dir)) - } - return { markers, gateDirs } -} - -function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { +function diffBase( + prev: WorktreeBaseDirectorySnapshot, + next: WorktreeBaseDirectorySnapshot +): WorktreeBasePollEvent[] { const events: WorktreeBasePollEvent[] = [] for (const [dir, marker] of next.markers) { if (marker && prev.markers.get(dir) !== true) { @@ -191,13 +114,14 @@ async function startBasePoller( onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, visibility: WorktreePollerWindowVisibility, - onFullScan?: () => void + onFullScan: (() => void) | undefined, + scanLimits: Partial ): Promise { let disposed = false let ticking = false let tickCount = 0 - let snapshot = await snapshotBase(target.path, getRepos()) - let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) + let snapshot = await takeWorktreeBaseDirectorySnapshot(target.path, getRepos(), scanLimits) + let gateSignatures = await collectWorktreeBaseDirectorySignatures(snapshot.gateDirs) let timer: ReturnType | null = null let parkedWhileHidden = false // dir → tick when first seen without a `.git` marker @@ -210,8 +134,8 @@ async function startBasePoller( const fullScan = async (): Promise => { onFullScan?.() - const next = await snapshotBase(target.path, getRepos()) - const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature)) + const next = await takeWorktreeBaseDirectorySnapshot(target.path, getRepos(), scanLimits) + const nextSignatures = await collectWorktreeBaseDirectorySignatures(next.gateDirs) if (disposed) { return } @@ -238,7 +162,7 @@ async function startBasePoller( const checkPendingMarkers = async (): Promise => { const events: WorktreeBasePollEvent[] = [] for (const dir of pendingMarkers.keys()) { - if (await hasGitMarker(dir)) { + if (await hasWorktreeGitMarker(dir)) { pendingMarkers.delete(dir) snapshot.markers.set(dir, true) events.push({ type: 'create', path: join(dir, '.git') }) @@ -257,7 +181,7 @@ async function startBasePoller( } // Idle fast path: when the dirs whose listings define the candidate set // are untouched, skip the readdir + per-candidate stat fan-out entirely. - const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) + const signatures = await collectWorktreeBaseDirectorySignatures(snapshot.gateDirs) const gateChanged = signatures.length !== gateSignatures.length || signatures.some((sig, index) => sig !== gateSignatures[index]) @@ -349,8 +273,17 @@ export async function startWorktreeBaseDirectoryPoller( pollIntervalMs, platform, visibility, - options.onFullScan + options.onFullScan, + options.scanLimits ) } - return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan) + return startBasePoller( + target, + getRepos, + onEvents, + pollIntervalMs, + visibility, + options.onFullScan, + options.scanLimits ?? {} + ) } diff --git a/src/main/ipc/worktree-base-directory-snapshot.ts b/src/main/ipc/worktree-base-directory-snapshot.ts new file mode 100644 index 00000000000..197c0540009 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-snapshot.ts @@ -0,0 +1,109 @@ +import { opendir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import type { WorktreeBaseRepoWatchConfig } from './worktree-base-directory-event-filter' +import { + WorktreePollingScanBudget, + type WorktreePollingScanLimits +} from './worktree-polling-scan-budget' + +export type WorktreeBaseDirectorySnapshot = { + markers: Map + gateDirs: string[] +} + +function statSignature(value: { mtimeMs: number; ctimeMs: number; ino: number }): string { + return `${value.mtimeMs}:${value.ctimeMs}:${value.ino}` +} + +async function directorySignature(path: string): Promise { + try { + return statSignature(await stat(path)) + } catch { + return 'missing' + } +} + +export async function hasWorktreeGitMarker(dir: string): Promise { + try { + await stat(join(dir, '.git')) + return true + } catch { + return false + } +} + +export async function takeWorktreeBaseDirectorySnapshot( + rootPath: string, + repos: ReadonlyMap, + scanLimits: Partial +): Promise { + const markers = new Map() + const gateDirs = [rootPath] + const budget = new WorktreePollingScanBudget(scanLimits) + budget.claimRetainedPath(rootPath) + let includeFlat = false + const nestedRepoNames = new Set() + for (const config of repos.values()) { + budget.claimRepoConfig(config.repoId, config.repoName) + if (config.nestWorkspaces) { + nestedRepoNames.add(normalizeRuntimePathForComparison(config.repoName)) + } else { + includeFlat = true + } + } + + let rootDirectory + try { + rootDirectory = await opendir(rootPath, { bufferSize: 32 }) + } catch { + return { markers, gateDirs } + } + + const candidates: string[] = [] + for await (const entry of rootDirectory) { + budget.claimEntry() + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + continue + } + const entryPath = join(rootPath, entry.name) + if (includeFlat) { + budget.claimRetainedPath(entryPath) + candidates.push(entryPath) + } + if (!nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { + continue + } + budget.claimRetainedPath(entryPath) + gateDirs.push(entryPath) + let subDirectory + try { + subDirectory = await opendir(entryPath, { bufferSize: 32 }) + } catch { + continue + } + for await (const sub of subDirectory) { + budget.claimEntry() + if (sub.isDirectory() || sub.isSymbolicLink()) { + const candidatePath = join(entryPath, sub.name) + budget.claimRetainedPath(candidatePath) + candidates.push(candidatePath) + } + } + } + + for (const dir of candidates) { + markers.set(dir, await hasWorktreeGitMarker(dir)) + } + return { markers, gateDirs } +} + +export async function collectWorktreeBaseDirectorySignatures( + paths: readonly string[] +): Promise { + const signatures: string[] = [] + for (const path of paths) { + signatures.push(await directorySignature(path)) + } + return signatures +} diff --git a/src/main/ipc/worktree-base-directory-watch-targets-bounds.test.ts b/src/main/ipc/worktree-base-directory-watch-targets-bounds.test.ts new file mode 100644 index 00000000000..be4151e0c64 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watch-targets-bounds.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings, Repo } from '../../shared/types' +import { + buildWorktreeBaseDirectoryWatchTargets, + clearWorktreeBaseDirectoryWatchTargetWarnings +} from './worktree-base-directory-watch-targets' +import { WORKTREE_POLLING_MAX_REPO_CONFIGS } from './worktree-polling-scan-budget' + +const settings = { + workspaceDir: '/worktrees', + nestWorkspaces: false +} as GlobalSettings + +function folderRepo(index: number): Repo { + return { + id: `repo-${index}`, + path: `/repos/${index}`, + displayName: `Repo ${index}`, + badgeColor: '#000000', + addedAt: index, + kind: 'folder' + } +} + +describe('worktree watcher target repo bounds', () => { + afterEach(() => { + clearWorktreeBaseDirectoryWatchTargetWarnings() + vi.restoreAllMocks() + }) + + it('accepts the exact repo count before hydrating repos', async () => { + const repos = Array.from({ length: WORKTREE_POLLING_MAX_REPO_CONFIGS }, (_, index) => + folderRepo(index) + ) + const getRepos = vi.fn(() => repos) + + await expect( + buildWorktreeBaseDirectoryWatchTargets({ + getRepoCount: () => repos.length, + getRepos, + getSettings: () => settings + } as never) + ).resolves.toEqual(new Map()) + expect(getRepos).toHaveBeenCalledOnce() + }) + + it('rejects one repo over the cap without hydrating the repo array', async () => { + const getRepos = vi.fn(() => { + throw new Error('must not hydrate') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect( + buildWorktreeBaseDirectoryWatchTargets({ + getRepoCount: () => WORKTREE_POLLING_MAX_REPO_CONFIGS + 1, + getRepos, + getSettings: () => settings + } as never) + ).resolves.toEqual(new Map()) + expect(getRepos).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledOnce() + }) + + it('resumes target construction after the repo count returns below the cap', async () => { + let repoCount = WORKTREE_POLLING_MAX_REPO_CONFIGS + 1 + const getRepos = vi.fn(() => [folderRepo(1)]) + const store = { + getRepoCount: () => repoCount, + getRepos, + getSettings: () => settings + } + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await buildWorktreeBaseDirectoryWatchTargets(store as never) + expect(getRepos).not.toHaveBeenCalled() + + repoCount = 1 + await buildWorktreeBaseDirectoryWatchTargets(store as never) + expect(getRepos).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/worktree-base-directory-watch-targets.ts b/src/main/ipc/worktree-base-directory-watch-targets.ts index 1283333ee95..b98594d0db9 100644 --- a/src/main/ipc/worktree-base-directory-watch-targets.ts +++ b/src/main/ipc/worktree-base-directory-watch-targets.ts @@ -27,9 +27,11 @@ import type { WorktreeBaseWatchKind, WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { WORKTREE_POLLING_MAX_REPO_CONFIGS } from './worktree-polling-scan-budget' const missingRootWarnings = new Set() const skippedWslWarnings = new Set() +let repoCapacityWarningEmitted = false function normalizeWatchKey(pathValue: string): string { return normalizeRuntimePathForComparison(normalize(pathValue)) @@ -179,8 +181,23 @@ async function maybeAddBaseTarget( export async function buildWorktreeBaseDirectoryWatchTargets( store: Store ): Promise> { - const settings = store.getSettings() const targets = new Map() + const repoCount = store.getRepoCount() + if ( + !Number.isSafeInteger(repoCount) || + repoCount < 0 || + repoCount > WORKTREE_POLLING_MAX_REPO_CONFIGS + ) { + if (!repoCapacityWarningEmitted) { + console.warn( + `[worktree-base-watcher] skipping background watchers for ${repoCount} repos; limit is ${WORKTREE_POLLING_MAX_REPO_CONFIGS}` + ) + repoCapacityWarningEmitted = true + } + return targets + } + repoCapacityWarningEmitted = false + const settings = store.getSettings() for (const repo of store.getRepos()) { if (isFolderRepo(repo)) { continue @@ -198,4 +215,5 @@ export async function buildWorktreeBaseDirectoryWatchTargets( export function clearWorktreeBaseDirectoryWatchTargetWarnings(): void { missingRootWarnings.clear() skippedWslWarnings.clear() + repoCapacityWarningEmitted = false } diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 0a5773b13a0..aafa690ea5f 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -73,6 +73,7 @@ function makeRepo(overrides: Partial = {}): Repo { function makeStore(repos: Repo[]) { return { getSettings: () => settings, + getRepoCount: () => repos.length, getRepos: () => repos } } diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 85cd501a2eb..ab0a19d8540 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -1,5 +1,6 @@ import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' +import { forEachWithConcurrency } from '../../shared/map-with-concurrency' import { notifyWorktreeGitStatusMetadataChanged, notifyWorktreesChanged } from './worktree-remote' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { @@ -34,6 +35,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & { } const WATCH_DEBOUNCE_MS = 250 +export const WORKTREE_WATCH_CLOSE_LIMIT = 8 const activeWatches = new Map() let syncGeneration = 0 let scheduledSync: ReturnType | null = null @@ -327,10 +329,8 @@ export function scheduleCurrentWorktreeBaseDirectoryWatcherSync(): void { export async function disposeWorktreeBaseDirectoryWatchers(): Promise { syncGeneration++ latestSyncContext = null - if (scheduledSync) { - clearTimeout(scheduledSync) - scheduledSync = null - } - await Promise.all([...activeWatches.keys()].map((key) => removeWatch(key))) + clearTimeout(scheduledSync ?? undefined) + scheduledSync = null + await forEachWithConcurrency([...activeWatches.keys()], WORKTREE_WATCH_CLOSE_LIMIT, removeWatch) clearWorktreeBaseDirectoryWatchTargetWarnings() } diff --git a/src/main/ipc/worktree-common-git-directory.test.ts b/src/main/ipc/worktree-common-git-directory.test.ts new file mode 100644 index 00000000000..73c4fd76a07 --- /dev/null +++ b/src/main/ipc/worktree-common-git-directory.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import type { Repo } from '../../shared/types' +import { + MAX_GIT_DIRECTORY_POINTER_BYTES, + resolveWorktreeCommonGitDirectory +} from './worktree-common-git-directory' + +describe('resolveWorktreeCommonGitDirectory', () => { + const roots: string[] = [] + + afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + + async function makeRepo(): Promise<{ repo: Repo; root: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-common-git-dir-')) + roots.push(root) + const repoPath = join(root, 'checkout') + await mkdir(repoPath) + return { repo: { id: 'repo-1', path: repoPath } as Repo, root } + } + + it('resolves a normal linked-worktree pointer to its common directory', async () => { + const { repo, root } = await makeRepo() + await writeFile(join(repo.path, '.git'), 'gitdir: ../common/.git/worktrees/checkout\n') + + await expect(resolveWorktreeCommonGitDirectory(repo)).resolves.toBe( + resolve(root, 'common', '.git') + ) + }) + + it('rejects an oversized sparse local pointer without loading it', async () => { + const { repo } = await makeRepo() + const dotGitPath = join(repo.path, '.git') + await writeFile(dotGitPath, 'x') + await truncate(dotGitPath, MAX_GIT_DIRECTORY_POINTER_BYTES + 1) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(resolveWorktreeCommonGitDirectory(repo)).resolves.toBeNull() + }) + + it('rejects oversized provider content at the same boundary', async () => { + const { repo } = await makeRepo() + const readFile = vi.fn(async () => `gitdir: ${'x'.repeat(MAX_GIT_DIRECTORY_POINTER_BYTES)}\n`) + + await expect( + resolveWorktreeCommonGitDirectory(repo, { + stat: async () => ({ type: 'file', size: 1, mtime: 0 }), + readFile + }) + ).resolves.toBeNull() + expect(readFile).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/worktree-common-git-directory.ts b/src/main/ipc/worktree-common-git-directory.ts index a2ec6b0e990..6fd9946ffcf 100644 --- a/src/main/ipc/worktree-common-git-directory.ts +++ b/src/main/ipc/worktree-common-git-directory.ts @@ -1,5 +1,5 @@ import type { Stats } from 'node:fs' -import { readFile, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' import type { Repo } from '../../shared/types' import { getRuntimePathBasename, @@ -7,6 +7,9 @@ import { resolveRuntimePath } from '../../shared/cross-platform-path' import type { FileStat } from '../providers/types' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' + +export const MAX_GIT_DIRECTORY_POINTER_BYTES = 64 * 1024 type GitDirectoryStat = Stats | FileStat @@ -41,7 +44,12 @@ export async function resolveWorktreeCommonGitDirectory( ): Promise { const dotGitPath = resolveRuntimePath(repo.path, '.git') const statPath = access.stat ?? stat - const readText = access.readFile ?? ((path: string) => readFile(path, 'utf8')) + const readText = + access.readFile ?? + (async (path: string) => + (await readNodeFileWithinLimit(path, MAX_GIT_DIRECTORY_POINTER_BYTES)).buffer.toString( + 'utf8' + )) try { const dotGitStat = await statPath(dotGitPath) if (isDirectoryStat(dotGitStat)) { @@ -51,6 +59,9 @@ export async function resolveWorktreeCommonGitDirectory( return null } const content = await readText(dotGitPath) + if (Buffer.byteLength(content, 'utf8') > MAX_GIT_DIRECTORY_POINTER_BYTES) { + return null + } const gitDir = content.match(/^gitdir:\s*(.+)\s*$/m)?.[1]?.trim() if (!gitDir) { return null diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index ed0043b3c5b..3c16440636d 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -1,270 +1,41 @@ -import { readdir, stat } from 'node:fs/promises' -import { join } from 'node:path' import type { WorktreeBasePollEvent, WorktreeBaseSubscription, WorktreePollerWindowVisibility } from './worktree-base-directory-poller' +import type { WorktreePollingScanLimits } from './worktree-polling-scan-budget' +import { + diffGitCommon, + PRIMARY_CHECKOUT_METADATA_FILES, + snapshotGitCommon +} from './worktree-git-common-snapshot' + +export { PRIMARY_CHECKOUT_METADATA_FILES } -// Shared with the darwin primary-metadata poll so platforms cannot drift. -// `logs/HEAD` catches head moves; `config.worktree` carries the sparse flag. -export const PRIMARY_CHECKOUT_METADATA_FILES = [ - 'HEAD', - 'packed-refs', - 'index', - 'config.worktree', - 'logs/HEAD' -] -const LINKED_WORKTREE_STRUCTURAL_METADATA_FILES = ['HEAD', 'gitdir', 'locked', 'config.worktree'] -const LINKED_WORKTREE_INDEX_FILE = 'index' -const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD') // Why: the entry-dir signature gate can miss same-granule index rewrites on // coarse-mtime filesystems; a periodic ungated re-stat bounds that miss the // same way the base poller's backstop rescan does. const INDEX_BACKSTOP_TICKS = 15 -function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { - return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` -} - -async function dirSignature(path: string): Promise { - try { - // Why: keep `size` — on a coarse-timestamp filesystem a same-granule directory - // allocation change would otherwise slip the readdir gate to the backstop. - const s = await stat(path) - return `${statSignature(s)}:${s.size}` - } catch { - return 'missing' - } -} - -async function fileSignature(path: string): Promise { - try { - const s = await stat(path) - return s.isFile() ? `${statSignature(s)}:${s.size}` : null - } catch { - return null - } -} - -type GitCommonEntrySnapshot = { - dirSignature: string - structuralSignatures: Map - indexSignature: string | null - headLogSignature: string | null -} - -type GitCommonSnapshot = { - worktreesDirSignature: string - entries: Map - primarySignatures: Map - didFullScan: boolean -} - -async function snapshotGitCommonEntry( - entryPath: string, - previous: GitCommonEntrySnapshot | undefined, - forceFullScan: boolean -): Promise { - // Why: HEAD, gitdir, locked, config.worktree and logs/HEAD are rewritten in place without bumping - // the entry-dir mtime, so — like the pre-idle-gate poller — they are re-stat'd EVERY tick, never - // gated behind the dir signature (else a raw HEAD/structural rewrite would slip to the ~30s - // backstop). Only `index` rides the entry-dir signature (its same-dir rewrites are index-backstop-bounded). - const structuralSignatures = new Map() - const [nextDirSignature, headLogSignature] = await Promise.all([ - dirSignature(entryPath), - fileSignature(join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE)), - Promise.all( - LINKED_WORKTREE_STRUCTURAL_METADATA_FILES.map(async (name) => { - const signature = await fileSignature(join(entryPath, name)) - if (signature !== null) { - structuralSignatures.set(name, signature) - } - }) - ) - ]) - if (nextDirSignature === 'missing') { - // A transient stat failure must not masquerade as a removal; the parent listing is authoritative. - return ( - previous ?? { - dirSignature: nextDirSignature, - structuralSignatures, - indexSignature: null, - headLogSignature - } - ) - } - const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature - const indexSignature = shouldReadIndex - ? await fileSignature(join(entryPath, LINKED_WORKTREE_INDEX_FILE)) - : previous.indexSignature - return { - dirSignature: nextDirSignature, - structuralSignatures, - indexSignature, - headLogSignature - } -} - -async function snapshotPrimaryCheckoutSignatures( - commonDirPath: string -): Promise> { - const signatures = new Map() - await Promise.all( - PRIMARY_CHECKOUT_METADATA_FILES.map(async (name) => { - const signature = await fileSignature(join(commonDirPath, name)) - if (signature !== null) { - signatures.set(name, signature) - } - }) - ) - return signatures -} - -async function snapshotGitCommon( - commonDirPath: string, - previous?: GitCommonSnapshot, - includePrimary = true, - forceFullScan = false -): Promise { - const worktreesDir = join(commonDirPath, 'worktrees') - const [worktreesDirSignature, primarySignatures] = await Promise.all([ - dirSignature(worktreesDir), - includePrimary ? snapshotPrimaryCheckoutSignatures(commonDirPath) : new Map() - ]) - // Why: enumerate the worktrees dir EVERY tick rather than gating the readdir on its stat signature. - // A single readdir of a small dir is negligible next to the per-entry structural stats that already - // run each tick, and the signature gate could miss a same-granule add+remove on a coarse-mtime/FAT - // filesystem (its size/mtime/ino/ctime all collide), leaving a linked worktree add/remove undetected - // until the ~30s index backstop (#9882 review). The listing is the authoritative add/remove signal. - let entryPaths: string[] - try { - const entries = await readdir(worktreesDir, { withFileTypes: true }) - entryPaths = entries - .filter((entry) => entry.isDirectory()) - .map((entry) => join(worktreesDir, entry.name)) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - // Dir genuinely absent (no linked worktrees, or all removed) → authoritative empty listing. - entryPaths = [] - } else { - // Why: a TRANSIENT readdir failure (EIO/ESTALE/EMFILE, network/SSH hiccup) must not masquerade as - // "every worktree removed" — that would emit false delete events (and false creates next tick). - // Reuse the known entries so per-entry stats still run; a real removal surfaces as that entry's own - // stat miss (handled in snapshotGitCommonEntry), and the next successful readdir catches any add. - entryPaths = previous ? [...previous.entries.keys()] : [] - } - } - - const entries = new Map() - await Promise.all( - entryPaths.map(async (entryPath) => { - const previousEntry = previous?.entries.get(entryPath) - entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) - }) - ) - // Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan - // now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost — - // rather than the always-run worktrees-dir readdir. - return { - worktreesDirSignature, - entries, - primarySignatures, - didFullScan: forceFullScan - } -} - -function classifySignatureDiff( - prevSignature: string | null | undefined, - nextSignature: string | null | undefined -): 'create' | 'update' | 'delete' | null { - if (prevSignature == null && nextSignature == null) { - return null - } - if (prevSignature == null) { - return 'create' - } - if (nextSignature == null) { - return 'delete' - } - return prevSignature === nextSignature ? null : 'update' -} - -function diffSignatureMaps( - prev: Map, - next: Map, - resolvePath: (name: string) => string -): WorktreeBasePollEvent[] { - const events: WorktreeBasePollEvent[] = [] - const names = new Set([...prev.keys(), ...next.keys()]) - for (const name of names) { - const type = classifySignatureDiff(prev.get(name), next.get(name)) - if (type) { - events.push({ type, path: resolvePath(name) }) - } - } - return events -} - -function diffGitCommon( - commonDirPath: string, - prev: GitCommonSnapshot, - next: GitCommonSnapshot -): WorktreeBasePollEvent[] { - const events: WorktreeBasePollEvent[] = [] - const worktreesDir = join(commonDirPath, 'worktrees') - const worktreesDirDiff = classifySignatureDiff( - prev.worktreesDirSignature, - next.worktreesDirSignature - ) - if (worktreesDirDiff) { - events.push({ type: worktreesDirDiff, path: worktreesDir }) - } - for (const [entryPath, entry] of next.entries) { - const prevEntry = prev.entries.get(entryPath) - if (!prevEntry) { - events.push({ type: 'create', path: entryPath }) - continue - } - events.push( - ...diffSignatureMaps(prevEntry.structuralSignatures, entry.structuralSignatures, (name) => - join(entryPath, name) - ) - ) - const indexDiff = classifySignatureDiff(prevEntry.indexSignature, entry.indexSignature) - if (indexDiff) { - events.push({ type: indexDiff, path: join(entryPath, LINKED_WORKTREE_INDEX_FILE) }) - } - const headLogDiff = classifySignatureDiff(prevEntry.headLogSignature, entry.headLogSignature) - if (headLogDiff) { - events.push({ type: headLogDiff, path: join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE) }) - } - } - for (const entryPath of prev.entries.keys()) { - if (!next.entries.has(entryPath)) { - events.push({ type: 'delete', path: entryPath }) - } - } - events.push( - ...diffSignatureMaps(prev.primarySignatures, next.primarySignatures, (name) => - join(commonDirPath, name) - ) - ) - return events -} - export async function startGitCommonPolling( commonDirPath: string, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, - includePrimary = true + includePrimary = true, + scanLimits: Partial = {} ): Promise { let disposed = false let ticking = false let tickCount = 0 - let snapshot = await snapshotGitCommon(commonDirPath, undefined, includePrimary) + let snapshot = await snapshotGitCommon( + commonDirPath, + undefined, + includePrimary, + false, + scanLimits + ) let timer: ReturnType | null = null let parkedWhileHidden = false @@ -291,7 +62,8 @@ export async function startGitCommonPolling( commonDirPath, snapshot, includePrimary, - shouldForceFullScan + shouldForceFullScan, + scanLimits ) if (disposed) { return diff --git a/src/main/ipc/worktree-git-common-snapshot.ts b/src/main/ipc/worktree-git-common-snapshot.ts new file mode 100644 index 00000000000..229c36e1ae2 --- /dev/null +++ b/src/main/ipc/worktree-git-common-snapshot.ts @@ -0,0 +1,258 @@ +import { opendir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import type { WorktreeBasePollEvent } from './worktree-base-directory-poller' +import { + WorktreePollingScanBudget, + type WorktreePollingScanLimits +} from './worktree-polling-scan-budget' + +export const PRIMARY_CHECKOUT_METADATA_FILES = [ + 'HEAD', + 'packed-refs', + 'index', + 'config.worktree', + 'logs/HEAD' +] + +const LINKED_WORKTREE_STRUCTURAL_METADATA_FILES = ['HEAD', 'gitdir', 'locked', 'config.worktree'] +const LINKED_WORKTREE_INDEX_FILE = 'index' +const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD') +const GIT_COMMON_ENTRY_STAT_CONCURRENCY = 16 + +function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { + return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` +} + +async function dirSignature(path: string): Promise { + try { + const stats = await stat(path) + return `${statSignature(stats)}:${stats.size}` + } catch { + return 'missing' + } +} + +async function fileSignature(path: string): Promise { + try { + const stats = await stat(path) + return stats.isFile() ? `${statSignature(stats)}:${stats.size}` : null + } catch { + return null + } +} + +type GitCommonEntrySnapshot = { + dirSignature: string + structuralSignatures: Map + indexSignature: string | null + headLogSignature: string | null +} + +export type GitCommonSnapshot = { + worktreesDirSignature: string + entries: Map + primarySignatures: Map + didFullScan: boolean +} + +async function snapshotGitCommonEntry( + entryPath: string, + previous: GitCommonEntrySnapshot | undefined, + forceFullScan: boolean +): Promise { + // Why: structural leaves can change without the entry directory changing; only the index uses the gate. + const structuralSignatures = new Map() + const [nextDirSignature, headLogSignature] = await Promise.all([ + dirSignature(entryPath), + fileSignature(join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE)), + Promise.all( + LINKED_WORKTREE_STRUCTURAL_METADATA_FILES.map(async (name) => { + const signature = await fileSignature(join(entryPath, name)) + if (signature !== null) { + structuralSignatures.set(name, signature) + } + }) + ) + ]) + if (nextDirSignature === 'missing') { + // A transient stat failure must not masquerade as a removal; the parent listing is authoritative. + return ( + previous ?? { + dirSignature: nextDirSignature, + structuralSignatures, + indexSignature: null, + headLogSignature + } + ) + } + const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature + const indexSignature = shouldReadIndex + ? await fileSignature(join(entryPath, LINKED_WORKTREE_INDEX_FILE)) + : previous.indexSignature + return { + dirSignature: nextDirSignature, + structuralSignatures, + indexSignature, + headLogSignature + } +} + +async function snapshotPrimaryCheckoutSignatures( + commonDirPath: string +): Promise> { + const signatures = new Map() + await Promise.all( + PRIMARY_CHECKOUT_METADATA_FILES.map(async (name) => { + const signature = await fileSignature(join(commonDirPath, name)) + if (signature !== null) { + signatures.set(name, signature) + } + }) + ) + return signatures +} + +export async function snapshotGitCommon( + commonDirPath: string, + previous?: GitCommonSnapshot, + includePrimary = true, + forceFullScan = false, + scanLimits: Partial = {} +): Promise { + const worktreesDir = join(commonDirPath, 'worktrees') + const [worktreesDirSignature, primarySignatures] = await Promise.all([ + dirSignature(worktreesDir), + includePrimary ? snapshotPrimaryCheckoutSignatures(commonDirPath) : new Map() + ]) + // Why: listing is authoritative for add/remove; directory timestamps can collide on coarse filesystems. + const budget = new WorktreePollingScanBudget(scanLimits) + let directory: Awaited> | undefined + let entryPaths: string[] = [] + try { + directory = await opendir(worktreesDir, { bufferSize: 32 }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + entryPaths = previous ? [...previous.entries.keys()] : [] + } + } + if (directory) { + for await (const entry of directory) { + budget.claimEntry() + if (!entry.isDirectory()) { + continue + } + const entryPath = join(worktreesDir, entry.name) + budget.claimRetainedPath(entryPath) + entryPaths.push(entryPath) + } + } else { + for (const entryPath of entryPaths) { + budget.claimRetainedPath(entryPath) + } + } + const entrySnapshots = await mapWithConcurrency( + entryPaths, + GIT_COMMON_ENTRY_STAT_CONCURRENCY, + async (entryPath) => ({ + entryPath, + snapshot: await snapshotGitCommonEntry( + entryPath, + previous?.entries.get(entryPath), + forceFullScan + ) + }) + ) + const entries = new Map() + for (const { entryPath, snapshot } of entrySnapshots) { + entries.set(entryPath, snapshot) + } + return { + worktreesDirSignature, + entries, + primarySignatures, + didFullScan: forceFullScan + } +} + +function classifySignatureDiff( + previous: string | null | undefined, + next: string | null | undefined +): 'create' | 'update' | 'delete' | null { + if (previous == null && next == null) { + return null + } + if (previous == null) { + return 'create' + } + if (next == null) { + return 'delete' + } + return previous === next ? null : 'update' +} + +function diffSignatureMaps( + previous: Map, + next: Map, + resolvePath: (name: string) => string +): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + const names = new Set([...previous.keys(), ...next.keys()]) + for (const name of names) { + const type = classifySignatureDiff(previous.get(name), next.get(name)) + if (type) { + events.push({ type, path: resolvePath(name) }) + } + } + return events +} + +export function diffGitCommon( + commonDirPath: string, + previous: GitCommonSnapshot, + next: GitCommonSnapshot +): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + const worktreesDir = join(commonDirPath, 'worktrees') + const worktreesDirDiff = classifySignatureDiff( + previous.worktreesDirSignature, + next.worktreesDirSignature + ) + if (worktreesDirDiff) { + events.push({ type: worktreesDirDiff, path: worktreesDir }) + } + for (const [entryPath, entry] of next.entries) { + const previousEntry = previous.entries.get(entryPath) + if (!previousEntry) { + events.push({ type: 'create', path: entryPath }) + continue + } + events.push( + ...diffSignatureMaps(previousEntry.structuralSignatures, entry.structuralSignatures, (name) => + join(entryPath, name) + ) + ) + const indexDiff = classifySignatureDiff(previousEntry.indexSignature, entry.indexSignature) + if (indexDiff) { + events.push({ type: indexDiff, path: join(entryPath, LINKED_WORKTREE_INDEX_FILE) }) + } + const headLogDiff = classifySignatureDiff( + previousEntry.headLogSignature, + entry.headLogSignature + ) + if (headLogDiff) { + events.push({ type: headLogDiff, path: join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE) }) + } + } + for (const entryPath of previous.entries.keys()) { + if (!next.entries.has(entryPath)) { + events.push({ type: 'delete', path: entryPath }) + } + } + events.push( + ...diffSignatureMaps(previous.primarySignatures, next.primarySignatures, (name) => + join(commonDirPath, name) + ) + ) + return events +} diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 717ae304f14..6dab9765130 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { appendFile, mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' import { chmodSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -19,6 +20,20 @@ vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: vi.fn() })) +// Records every stat target so a test can assert which paths a parked poll stopped touching. +const { statCalls } = vi.hoisted(() => ({ statCalls: [] as string[] })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: (...args: Parameters) => { + statCalls.push(String(args[0])) + return actual.stat(...args) + } + } +}) + const POLL_MS = 25 const alwaysVisible: WorktreePollerWindowVisibility = { @@ -30,18 +45,18 @@ function createVisibilityHarness(): { source: WorktreePollerWindowVisibility hide: () => void show: () => void + listenerCount: () => number } { let visible = true - let listener: (() => void) | null = null + // A set, not a single slot: the darwin path parks two independent watches. + const listeners = new Set<() => void>() return { source: { isWindowVisible: () => visible, onWindowBecameVisible: (nextListener) => { - listener = nextListener + listeners.add(nextListener) return () => { - if (listener === nextListener) { - listener = null - } + listeners.delete(nextListener) } } }, @@ -50,8 +65,11 @@ function createVisibilityHarness(): { }, show: () => { visible = true - listener?.() - } + for (const listener of listeners) { + listener() + } + }, + listenerCount: () => listeners.size } } @@ -70,6 +88,7 @@ describe('worktree git-common narrow watch (darwin)', () => { afterEach(async () => { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) childSubscriptions = [] + statCalls.length = 0 subscribeMock.mockReset() }) @@ -206,6 +225,110 @@ describe('worktree git-common narrow watch (darwin)', () => { }) }) + async function startHiddenExistencePoll(visibility: { + source: WorktreePollerWindowVisibility + hide: () => void + }): Promise<{ commonDir: string; worktreesDir: string; received: WorktreeBasePollEvent[][] }> { + const commonDir = await makeCommonDir(false) + const received: WorktreeBasePollEvent[][] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + visibility.source + ) + cleanups.push(() => watch.unsubscribe()) + visibility.hide() + // Let the armed poll observe the hidden window and park itself. + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 4)) + statCalls.length = 0 + return { commonDir, worktreesDir: join(commonDir, 'worktrees'), received } + } + + it('parks the existence poll while the window is hidden', async () => { + installSubscribeMock() + const visibility = createVisibilityHarness() + const { worktreesDir, received } = await startHiddenExistencePoll(visibility) + + await mkdir(worktreesDir) + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 4)) + + expect(statCalls.filter((path) => path === worktreesDir)).toHaveLength(0) + expect(subscribeMock).not.toHaveBeenCalled() + expect(received.flat()).toHaveLength(0) + }) + + it('re-checks on show and still reports a worktrees dir created while hidden', async () => { + installSubscribeMock() + const visibility = createVisibilityHarness() + const { worktreesDir, received } = await startHiddenExistencePoll(visibility) + + await mkdir(worktreesDir) + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 4)) + expect(subscribeMock).not.toHaveBeenCalled() + + visibility.show() + // Promptly: the re-check stats on show, not a poll interval later. + expect(statCalls.filter((path) => path === worktreesDir)).toHaveLength(1) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(1) + }) + expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir }) + }) + + it('resumes polling when the dir is still absent on show', async () => { + installSubscribeMock() + const visibility = createVisibilityHarness() + const { worktreesDir } = await startHiddenExistencePoll(visibility) + + visibility.show() + await mkdir(worktreesDir) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(1) + }) + }) + + it('keeps polling and reporting while the window stays visible', async () => { + installSubscribeMock() + const visibility = createVisibilityHarness() + const commonDir = await makeCommonDir(false) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + visibility.source + ) + cleanups.push(() => watch.unsubscribe()) + + await mkdir(worktreesDir) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(1) + }) + expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir }) + }) + + it('drops both visibility subscriptions on dispose', async () => { + installSubscribeMock() + const visibility = createVisibilityHarness() + const commonDir = await makeCommonDir(false) + const watch = await startGitCommonWatch( + makeTarget(commonDir), + () => {}, + POLL_MS, + 'darwin', + visibility.source + ) + + // Narrow watch + primary-metadata poll each park on window visibility. + expect(visibility.listenerCount()).toBe(2) + await watch.unsubscribe() + expect(visibility.listenerCount()).toBe(0) + }) + it('keeps the native stream live while the primary poll is parked', async () => { installSubscribeMock() const commonDir = await makeCommonDir(true) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index a24adf8e357..7093c345794 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -11,6 +11,7 @@ import { PRIMARY_CHECKOUT_METADATA_FILES, startGitCommonPolling } from './worktree-git-common-polling' +import type { WorktreePollingScanLimits } from './worktree-polling-scan-budget' // Watches a repo's `/.git/worktrees` metadata plus the primary // checkout's shallow branch/index files — the only paths the git-common event @@ -149,13 +150,15 @@ async function startSnapshotDiffPoller( async function startGitCommonNarrowWatch( target: WorktreeBaseWatchTarget, onEvents: (events: WorktreeBasePollEvent[]) => void, - pollIntervalMs: number + pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility ): Promise { const worktreesDir = join(target.path, 'worktrees') let disposed = false let subscription: WorktreeBaseSubscription | null = null let existenceTimer: ReturnType | null = null let subscribing = false + let parkedWhileHidden = false const stopExistencePoll = (): void => { if (existenceTimer) { @@ -164,31 +167,60 @@ async function startGitCommonNarrowWatch( } } + const tryUpgradeToNarrowWatch = async (): Promise => { + if (disposed || subscribing || subscription) { + return + } + subscribing = true + try { + const installed = await trySubscribe() + if (installed && !disposed) { + stopExistencePoll() + // The dir appearing means a first linked worktree was just + // registered; surface it so the repo's worktree list refreshes. + onEvents([{ type: 'create', path: worktreesDir }]) + } + } finally { + subscribing = false + } + } + const armExistencePoll = (): void => { - if (disposed || existenceTimer) { + if (disposed || existenceTimer || subscription) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true return } existenceTimer = setInterval(() => { - if (disposed || subscribing || subscription) { + if (disposed) { return } - subscribing = true - void trySubscribe() - .then((installed) => { - if (installed && !disposed) { - stopExistencePoll() - // The dir appearing means a first linked worktree was just - // registered; surface it so the repo's worktree list refreshes. - onEvents([{ type: 'create', path: worktreesDir }]) - } - }) - .finally(() => { - subscribing = false - }) + // Why: a hidden window has nothing to refresh, so stop stat'ing the dir + // entirely instead of burning a syscall per repo per tick in the background. + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + stopExistencePoll() + return + } + void tryUpgradeToNarrowWatch() }, pollIntervalMs) existenceTimer.unref?.() } + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + // Why: the first linked worktree may have been registered while hidden — check + // now (emitting the create) rather than losing it for a full interval. + void tryUpgradeToNarrowWatch().finally(() => { + armExistencePoll() + }) + }) + const trySubscribe = async (): Promise => { try { const s = await stat(worktreesDir) @@ -269,6 +301,7 @@ async function startGitCommonNarrowWatch( unsubscribe: async () => { disposed = true stopExistencePoll() + unsubscribeVisibility() const current = subscription subscription = null if (current) { @@ -284,11 +317,12 @@ export async function startGitCommonWatch( pollIntervalMs: number, platform: NodeJS.Platform, visibility: WorktreePollerWindowVisibility, - onFullScan?: () => void + onFullScan?: () => void, + scanLimits: Partial = {} ): Promise { if (platform === 'darwin') { const [narrowWatch, primaryMetadataPoll] = await Promise.all([ - startGitCommonNarrowWatch(target, onEvents, pollIntervalMs), + startGitCommonNarrowWatch(target, onEvents, pollIntervalMs, visibility), startSnapshotDiffPoller( () => snapshotPrimaryCheckoutMetadata(target.path), onEvents, @@ -303,5 +337,13 @@ export async function startGitCommonWatch( } } } - return startGitCommonPolling(target.path, onEvents, pollIntervalMs, visibility, onFullScan) + return startGitCommonPolling( + target.path, + onEvents, + pollIntervalMs, + visibility, + onFullScan, + true, + scanLimits + ) } diff --git a/src/main/ipc/worktree-head-identity-reader.test.ts b/src/main/ipc/worktree-head-identity-reader.test.ts index c722d171ee0..e4055e8a0bf 100644 --- a/src/main/ipc/worktree-head-identity-reader.test.ts +++ b/src/main/ipc/worktree-head-identity-reader.test.ts @@ -1,8 +1,13 @@ import { afterEach, describe, expect, it } from 'vitest' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' +import { + MAX_GIT_HEAD_METADATA_BYTES, + MAX_LINKED_WORKTREE_ENTRIES, + MAX_PACKED_REFS_BYTES, + readGitCommonHeadIdentities +} from './worktree-head-identity-reader' const OID_A = 'a'.repeat(40) const OID_B = 'b'.repeat(40) @@ -149,4 +154,32 @@ describe('readGitCommonHeadIdentities', () => { expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) }) + + it('rejects metadata that grows beyond the exact file limits', async () => { + const commonDir = await makeCommonDir() + const headPath = join(commonDir, 'HEAD') + await writeFile(headPath, 'x') + await truncate(headPath, MAX_GIT_HEAD_METADATA_BYTES + 1) + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + + await writeFile(headPath, 'ref: refs/heads/main\n') + const packedRefsPath = join(commonDir, 'packed-refs') + await writeFile(packedRefsPath, 'x') + await truncate(packedRefsPath, MAX_PACKED_REFS_BYTES + 1) + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + }) + + it('fails closed instead of retaining a partial oversized worktree listing', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), `${OID_A}\n`) + const worktreesDir = join(commonDir, 'worktrees') + await mkdir(worktreesDir) + await Promise.all( + Array.from({ length: MAX_LINKED_WORKTREE_ENTRIES + 1 }, (_, index) => + writeFile(join(worktreesDir, `entry-${index}`), '') + ) + ) + + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + }) }) diff --git a/src/main/ipc/worktree-head-identity-reader.ts b/src/main/ipc/worktree-head-identity-reader.ts index e99de115480..4219d8e8de5 100644 --- a/src/main/ipc/worktree-head-identity-reader.ts +++ b/src/main/ipc/worktree-head-identity-reader.ts @@ -1,16 +1,26 @@ -import { readdir, readFile } from 'node:fs/promises' +import { opendir } from 'node:fs/promises' import { basename, dirname, isAbsolute, join } from 'node:path' import type { WorktreeHeadIdentity } from '../../shared/types' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' // Why: the whole point of this reader is replacing `git worktree list` fanout // with bounded metadata-file reads, so head freshness never re-creates the // spawn pressure that stalled terminal input. Keep it spawn-free. const MAX_SYMREF_DEPTH = 5 +export const MAX_GIT_HEAD_METADATA_BYTES = 64 * 1024 +export const MAX_PACKED_REFS_BYTES = 16 * 1024 * 1024 +export const MAX_PACKED_REFS_ENTRIES = 100_000 +export const MAX_LINKED_WORKTREE_ENTRIES = 1_024 +const MAX_PACKED_REFS_RETAINED_BYTES = 16 * 1024 * 1024 +const MAX_IDENTITY_RETAINED_BYTES = 4 * 1024 * 1024 -async function readTrimmedFile(path: string): Promise { +async function readTrimmedFile( + path: string, + maxBytes = MAX_GIT_HEAD_METADATA_BYTES +): Promise { try { - return (await readFile(path, 'utf8')).trim() + return (await readNodeFileWithinLimit(path, maxBytes)).buffer.toString('utf8').trim() } catch { return null } @@ -19,19 +29,39 @@ async function readTrimmedFile(path: string): Promise { // packed-refs lines are ` `; `#` headers and `^` peel lines skipped. async function readPackedRefs(commonDirPath: string): Promise> { const refs = new Map() - const content = await readTrimmedFile(join(commonDirPath, 'packed-refs')) + const content = await readTrimmedFile(join(commonDirPath, 'packed-refs'), MAX_PACKED_REFS_BYTES) if (content === null) { return refs } - for (const line of content.split('\n')) { + let retainedBytes = 0 + for (let start = 0; start <= content.length; ) { + const newline = content.indexOf('\n', start) + const end = newline === -1 ? content.length : newline + if (end - start > MAX_GIT_HEAD_METADATA_BYTES) { + return new Map() + } + const line = content.slice(start, end) if (!line || line.startsWith('#') || line.startsWith('^')) { - continue + // Headers and peeled-object lines never resolve a branch head. + } else { + const separator = line.indexOf(' ') + if (separator > 0) { + const ref = line.slice(separator + 1).trim() + const oid = line.slice(0, separator) + retainedBytes += (ref.length + oid.length) * 2 + if ( + refs.size >= MAX_PACKED_REFS_ENTRIES || + retainedBytes > MAX_PACKED_REFS_RETAINED_BYTES + ) { + return new Map() + } + refs.set(ref, oid) + } } - const separator = line.indexOf(' ') - if (separator <= 0) { - continue + if (newline === -1) { + break } - refs.set(line.slice(separator + 1).trim(), line.slice(0, separator)) + start = newline + 1 } return refs } @@ -113,6 +143,16 @@ export async function readGitCommonHeadIdentities( (packedRefsPromise ??= readPackedRefs(commonDirPath)) const identities: WorktreeHeadIdentity[] = [] + let retainedIdentityBytes = 0 + const retainIdentity = (identity: WorktreeHeadIdentity): boolean => { + retainedIdentityBytes += + (identity.worktreePath.length + (identity.branch?.length ?? 0) + identity.head.length) * 2 + if (retainedIdentityBytes > MAX_IDENTITY_RETAINED_BYTES) { + return false + } + identities.push(identity) + return true + } // Only the standard `/.git` layout maps a common dir back to its // primary checkout path; bare/custom GIT_DIR layouts have no primary row. if (basename(commonDirPath) === '.git') { @@ -122,40 +162,51 @@ export async function readGitCommonHeadIdentities( dirname(commonDirPath), packedRefs ) - if (primary) { - identities.push(primary) + if (primary && !retainIdentity(primary)) { + return [] } } - let entries + let directory: Awaited> try { - entries = await readdir(join(commonDirPath, 'worktrees'), { withFileTypes: true }) + directory = await opendir(join(commonDirPath, 'worktrees')) } catch { return identities } - for (const entry of entries) { - if (!entry.isDirectory()) { - continue - } - const entryPath = join(commonDirPath, 'worktrees', entry.name) - const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir')) - if (!gitdirContent) { - continue - } - // `gitdir` holds `/.git`, absolute or (with relative-path - // worktrees) relative to the entry dir. - const gitdirAbsolute = isAbsolute(gitdirContent) - ? gitdirContent - : join(entryPath, gitdirContent) - const identity = await readHeadIdentity( - commonDirPath, - join(entryPath, 'HEAD'), - dirname(gitdirAbsolute), - packedRefs - ) - if (identity) { - identities.push(identity) + let entriesSeen = 0 + try { + for await (const entry of directory) { + entriesSeen += 1 + if (entriesSeen > MAX_LINKED_WORKTREE_ENTRIES) { + return [] + } + if (!entry.isDirectory()) { + continue + } + const entryPath = join(commonDirPath, 'worktrees', entry.name) + const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir')) + if (!gitdirContent) { + continue + } + // `gitdir` holds `/.git`, absolute or (with relative-path + // worktrees) relative to the entry dir. + const gitdirAbsolute = isAbsolute(gitdirContent) + ? gitdirContent + : join(entryPath, gitdirContent) + const identity = await readHeadIdentity( + commonDirPath, + join(entryPath, 'HEAD'), + dirname(gitdirAbsolute), + packedRefs + ) + if (identity && !retainIdentity(identity)) { + return [] + } } + } catch { + return identities + } finally { + await directory.close().catch(() => undefined) } return identities } diff --git a/src/main/ipc/worktree-include-copy-budget.test.ts b/src/main/ipc/worktree-include-copy-budget.test.ts new file mode 100644 index 00000000000..24e5d1ec33c --- /dev/null +++ b/src/main/ipc/worktree-include-copy-budget.test.ts @@ -0,0 +1,454 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createWorktreeCopyBudgetTracker, + formatWorktreeIncludeCopyWarning +} from './worktree-include-copy-budget' +import { createWorktreeCopiedPaths, createWorktreeLinkedPaths } from './worktree-symlinks' + +const posixIt = process.platform === 'win32' ? it.skip : it + +// A byte budget small enough to trip on a fixture that stays trivial on disk — +// the bound must be injectable or testing it would mean writing gigabytes. +const TINY_BYTE_BUDGET = { maxBytes: 64, maxEntries: 10_000 } +const TINY_ENTRY_BUDGET = { maxBytes: 1024 * 1024 * 1024, maxEntries: 3 } + +describe('worktree copy budget tracker', () => { + let root: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-copy-budget-')) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('admits a source that fits and reports its measured size', async () => { + writeFileSync(join(root, 'small.env'), 'A=1\n') + const tracker = createWorktreeCopyBudgetTracker(TINY_BYTE_BUDGET) + + await expect(tracker.admit(join(root, 'small.env'))).resolves.toEqual({ + withinBudget: true, + bytes: 4, + entries: 1 + }) + }) + + it('refuses a directory whose total bytes exceed the budget', async () => { + mkdirSync(join(root, 'node_modules')) + writeFileSync(join(root, 'node_modules', 'a'), 'x'.repeat(40)) + writeFileSync(join(root, 'node_modules', 'b'), 'x'.repeat(40)) + const tracker = createWorktreeCopyBudgetTracker(TINY_BYTE_BUDGET) + + await expect(tracker.admit(join(root, 'node_modules'))).resolves.toEqual({ + withinBudget: false, + reason: 'bytes' + }) + }) + + it('refuses a directory with too many entries even when it weighs nothing', async () => { + mkdirSync(join(root, 'cache')) + for (const name of ['a', 'b', 'c', 'd', 'e']) { + writeFileSync(join(root, 'cache', name), '') + } + const tracker = createWorktreeCopyBudgetTracker(TINY_ENTRY_BUDGET) + + await expect(tracker.admit(join(root, 'cache'))).resolves.toEqual({ + withinBudget: false, + reason: 'entries' + }) + }) + + it('spends one budget across every admitted source', async () => { + writeFileSync(join(root, 'first'), 'x'.repeat(40)) + writeFileSync(join(root, 'second'), 'x'.repeat(40)) + const tracker = createWorktreeCopyBudgetTracker(TINY_BYTE_BUDGET) + + await expect(tracker.admit(join(root, 'first'))).resolves.toMatchObject({ withinBudget: true }) + await expect(tracker.admit(join(root, 'second'))).resolves.toEqual({ + withinBudget: false, + reason: 'bytes' + }) + }) + + it('does not spend budget on a refused source, so a later small one still fits', async () => { + writeFileSync(join(root, 'big'), 'x'.repeat(200)) + writeFileSync(join(root, 'small'), 'A=1\n') + const tracker = createWorktreeCopyBudgetTracker(TINY_BYTE_BUDGET) + + await expect(tracker.admit(join(root, 'big'))).resolves.toEqual({ + withinBudget: false, + reason: 'bytes' + }) + await expect(tracker.admit(join(root, 'small'))).resolves.toMatchObject({ withinBudget: true }) + }) + + it('ignores the byte limit when the backend copies on write, but still counts entries', async () => { + writeFileSync(join(root, 'huge'), 'x'.repeat(400)) + const tracker = createWorktreeCopyBudgetTracker(TINY_BYTE_BUDGET) + + await expect( + tracker.admit(join(root, 'huge'), { bytesAreCopied: false }) + ).resolves.toMatchObject({ withinBudget: true }) + + // The same source is refused once its bytes actually have to be written. + const byteTracker = createWorktreeCopyBudgetTracker(TINY_BYTE_BUDGET) + await expect(byteTracker.admit(join(root, 'huge'))).resolves.toEqual({ + withinBudget: false, + reason: 'bytes' + }) + }) + + it('charges the walk itself so repeated over-budget sources cannot re-walk forever', async () => { + // 10 sources of 2 entries each, against a walk ceiling of maxEntries * 5. + for (let index = 0; index < 10; index += 1) { + mkdirSync(join(root, `dir-${index}`)) + writeFileSync(join(root, `dir-${index}`, 'one'), 'x'.repeat(200)) + } + writeFileSync(join(root, 'tiny'), '') + const tracker = createWorktreeCopyBudgetTracker({ maxBytes: 64, maxEntries: 4 }) + + for (let index = 0; index < 10; index += 1) { + await expect(tracker.admit(join(root, `dir-${index}`))).resolves.toMatchObject({ + withinBudget: false + }) + } + + // Nothing was admitted, so the copy budget itself is untouched and `tiny` + // would fit — it is refused purely because the walk ceiling is spent, and + // says so rather than blaming limits it never approached. + await expect(tracker.admit(join(root, 'tiny'))).resolves.toEqual({ + withinBudget: false, + reason: 'sizing' + }) + }) + + it('blames the walk ceiling, not the file limit, for an entry that would have fit', async () => { + // 9 sources of 2 entries each leave 2 of the 20-entry walk ceiling — enough + // to start measuring `fits`, not enough to finish — while the 4-entry copy + // budget stays completely unspent. + for (let index = 0; index < 9; index += 1) { + mkdirSync(join(root, `dir-${index}`)) + writeFileSync(join(root, `dir-${index}`, 'one'), 'x'.repeat(200)) + } + mkdirSync(join(root, 'fits')) + for (const name of ['a', 'b', 'c']) { + writeFileSync(join(root, 'fits', name), '') + } + const tracker = createWorktreeCopyBudgetTracker({ maxBytes: 64, maxEntries: 4 }) + + for (let index = 0; index < 9; index += 1) { + await tracker.admit(join(root, `dir-${index}`)) + } + + // `fits` is 4 entries against an untouched 4-entry budget — the only thing + // refusing it is the spent walk, so it must not be told it busted a limit. + await expect(tracker.admit(join(root, 'fits'))).resolves.toEqual({ + withinBudget: false, + reason: 'sizing' + }) + }) + + it('lets a small entry through after one huge entry is refused on file count', async () => { + // The regression this guards: sizing `node_modules` burns maxEntries + 1 + // walk, which without headroom would starve every entry listed after it. + mkdirSync(join(root, 'node_modules')) + for (let index = 0; index < 12; index += 1) { + writeFileSync(join(root, 'node_modules', `pkg-${index}`), '') + } + writeFileSync(join(root, '.env'), 'A=1\n') + const tracker = createWorktreeCopyBudgetTracker({ maxBytes: 1024, maxEntries: 4 }) + + await expect(tracker.admit(join(root, 'node_modules'))).resolves.toEqual({ + withinBudget: false, + reason: 'entries' + }) + await expect(tracker.admit(join(root, '.env'))).resolves.toMatchObject({ withinBudget: true }) + }) + + posixIt('counts a nested symlink without following it', async () => { + mkdirSync(join(root, 'payload')) + writeFileSync(join(root, 'payload', 'real'), 'x'.repeat(40)) + mkdirSync(join(root, 'dir')) + // Following this would re-count `payload` and blow the byte budget. + symlinkSync(join(root, 'payload'), join(root, 'dir', 'alias')) + const tracker = createWorktreeCopyBudgetTracker(TINY_BYTE_BUDGET) + + await expect(tracker.admit(join(root, 'dir'))).resolves.toMatchObject({ withinBudget: true }) + }) +}) + +describe('formatWorktreeIncludeCopyWarning', () => { + it('is undefined when nothing was skipped', () => { + expect(formatWorktreeIncludeCopyWarning([])).toBeUndefined() + }) + + it('names every skipped entry so the omission is not silent', () => { + const warning = formatWorktreeIncludeCopyWarning([ + { path: 'node_modules', reason: 'bytes' }, + { path: '.cache', reason: 'entries' } + ]) + + expect(warning).toContain('"node_modules"') + expect(warning).toContain('".cache"') + expect(warning).toContain('.worktreeinclude') + }) + + it('warns that an interrupted copy may have left leftovers behind', () => { + const warning = formatWorktreeIncludeCopyWarning([ + { path: 'models', reason: 'bytes', mayBePartial: true } + ]) + + expect(warning).toContain('"models" may hold a partial copy') + expect(warning).toContain('check it before reusing this workspace') + }) + + it('caps the partial-copy list too, so it cannot grow unbounded either', () => { + const warning = formatWorktreeIncludeCopyWarning( + Array.from({ length: 10 }, (_, index) => ({ + path: `dir-${index}`, + reason: 'bytes' as const, + mayBePartial: true + })) + ) + + expect(warning).toContain('"dir-4" and 5 more may hold a partial copy') + expect(warning).not.toContain('"dir-5"') + }) + + it('caps how many entries it names so the warning cannot grow unbounded', () => { + const warning = formatWorktreeIncludeCopyWarning( + Array.from({ length: 30 }, (_, index) => ({ + path: `dir-${index}`, + reason: 'bytes' as const + })) + ) + + expect(warning).toContain('"dir-0"') + expect(warning).toContain('and 25 more') + expect(warning).not.toContain('"dir-6"') + }) + + it('reads grammatically for a single skipped entry', () => { + const warning = formatWorktreeIncludeCopyWarning([{ path: 'node_modules', reason: 'bytes' }]) + + expect(warning).toContain('entry "node_modules" was not copied') + expect(warning).toContain('copying it would exceed') + expect(warning).toContain('Copy it in manually if this workspace needs it.') + }) + + it('does not quote the size limits at an entry that was never measured', () => { + const warning = formatWorktreeIncludeCopyWarning([ + { path: 'node_modules', reason: 'entries' }, + { path: '.env', reason: 'sizing' } + ]) + + expect(warning).toContain('"node_modules"') + expect(warning).toContain('earlier entries used up the budget for measuring') + // The limits belong to node_modules' sentence, not to `.env`'s. + expect(warning).not.toMatch(/"\.env"[^.]*file limit/u) + }) +}) + +describe('createWorktreeCopiedPaths copy budget', () => { + let root: string + let primary: string + let worktree: string + let warn: ReturnType + let error: ReturnType + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-copy-budget-paths-')) + primary = join(root, 'primary') + worktree = join(root, 'worktree') + mkdirSync(primary, { recursive: true }) + mkdirSync(worktree, { recursive: true }) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + error = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + error.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + it('refuses an over-budget directory before writing anything into the worktree', async () => { + mkdirSync(join(primary, 'node_modules')) + writeFileSync(join(primary, 'node_modules', 'pkg.js'), 'x'.repeat(200)) + + const skipped = await createWorktreeCopiedPaths(primary, worktree, ['node_modules'], { + platform: 'linux', + copyBudget: TINY_BYTE_BUDGET + }) + + expect(skipped).toEqual([{ path: 'node_modules', reason: 'bytes' }]) + expect(existsSync(join(worktree, 'node_modules'))).toBe(false) + }) + + it('refuses an entry that busts the file-count limit', async () => { + mkdirSync(join(primary, '.cache')) + for (const name of ['a', 'b', 'c', 'd', 'e']) { + writeFileSync(join(primary, '.cache', name), '') + } + + const skipped = await createWorktreeCopiedPaths(primary, worktree, ['.cache'], { + platform: 'linux', + copyBudget: TINY_ENTRY_BUDGET + }) + + expect(skipped).toEqual([{ path: '.cache', reason: 'entries' }]) + expect(existsSync(join(worktree, '.cache'))).toBe(false) + }) + + it('still copies the entries that fit alongside one that does not', async () => { + writeFileSync(join(primary, '.env'), 'A=1\n') + mkdirSync(join(primary, 'node_modules')) + writeFileSync(join(primary, 'node_modules', 'pkg.js'), 'x'.repeat(200)) + + const skipped = await createWorktreeCopiedPaths(primary, worktree, ['node_modules', '.env'], { + platform: 'linux', + copyBudget: TINY_BYTE_BUDGET + }) + + expect(skipped).toEqual([{ path: 'node_modules', reason: 'bytes' }]) + expect(readFileSync(join(worktree, '.env'), 'utf8')).toBe('A=1\n') + }) + + it('copies a normal small include fully and reports nothing skipped', async () => { + writeFileSync(join(primary, '.env'), 'A=1\n') + mkdirSync(join(primary, '.vscode')) + writeFileSync(join(primary, '.vscode', 'settings.json'), '{}') + + const skipped = await createWorktreeCopiedPaths(primary, worktree, ['.env', '.vscode'], { + platform: 'linux' + }) + + expect(skipped).toEqual([]) + expect(readFileSync(join(worktree, '.env'), 'utf8')).toBe('A=1\n') + expect(readFileSync(join(worktree, '.vscode', 'settings.json'), 'utf8')).toBe('{}') + }) + + it('still clones on macOS when only the byte budget would be exceeded', async () => { + mkdirSync(join(primary, 'node_modules')) + writeFileSync(join(primary, 'node_modules', 'pkg.js'), 'x'.repeat(200)) + const cloneWorktreePath = vi.fn(async () => undefined) + + const skipped = await createWorktreeCopiedPaths(primary, worktree, ['node_modules'], { + platform: 'darwin', + cloneWorktreePath, + copyBudget: TINY_BYTE_BUDGET + }) + + // An APFS clone is copy-on-write: bytes cost nothing, so refusing on bytes + // would deny a copy that is already free. + expect(skipped).toEqual([]) + expect(cloneWorktreePath).toHaveBeenCalledTimes(1) + }) + + it('does not run the macOS APFS clone for an entry over the file-count limit', async () => { + mkdirSync(join(primary, '.cache')) + for (const name of ['a', 'b', 'c', 'd', 'e']) { + writeFileSync(join(primary, '.cache', name), '') + } + const cloneWorktreePath = vi.fn(async () => undefined) + + const skipped = await createWorktreeCopiedPaths(primary, worktree, ['.cache'], { + platform: 'darwin', + cloneWorktreePath, + copyBudget: TINY_ENTRY_BUDGET + }) + + // Inodes are real work even on the clone path, so the entry limit holds. + expect(skipped).toEqual([{ path: '.cache', reason: 'entries' }]) + expect(cloneWorktreePath).not.toHaveBeenCalled() + }) + + it('does not fall back to a real copy when a failed clone would escape the byte budget', async () => { + mkdirSync(join(primary, 'models')) + writeFileSync(join(primary, 'models', 'checkpoint'), 'x'.repeat(500)) + // The clone was predicted (so bytes went uncharged) but fails mid-copy. + const cloneWorktreePath = vi.fn(async () => { + throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) + }) + + const skipped = await createWorktreeCopiedPaths(primary, worktree, ['models'], { + platform: 'darwin', + cloneWorktreePath, + copyBudget: TINY_BYTE_BUDGET + }) + + expect(cloneWorktreePath).toHaveBeenCalledTimes(1) + expect(skipped).toEqual([{ path: 'models', reason: 'bytes', mayBePartial: true }]) + // The whole point: no unbudgeted byte-for-byte copy ran behind the failure. + expect(existsSync(join(worktree, 'models'))).toBe(false) + }) + + it('still copies when the clone was never predicted, so its bytes were already charged', async () => { + // Sized so that billing it a second time would bust the 64-byte budget — + // that is what makes this test notice a missing short-circuit. + writeFileSync(join(primary, '.env'), 'x'.repeat(40)) + // A wedged df/diskutil makes the volume probe answer "no clone", so bytes + // are charged up front and the real-copy fallback must simply proceed. + const apfsCloneDeps = { + execFileAsync: async () => { + throw new Error('diskutil unavailable') + }, + randomUUID: () => 'test' + } + + const skipped = await createWorktreeCopiedPaths(primary, worktree, ['.env'], { + platform: 'darwin', + apfsCloneDeps, + copyBudget: TINY_BYTE_BUDGET + }) + + expect(skipped).toEqual([]) + expect(readFileSync(join(worktree, '.env'), 'utf8')).toBe('x'.repeat(40)) + }) + + it('bills a recovered clone fallback so a later entry sees the spent budget', async () => { + writeFileSync(join(primary, 'one'), 'x'.repeat(50)) + writeFileSync(join(primary, 'two'), 'x'.repeat(50)) + // Clone is predicted for both, then fails, so each falls back to a real + // copy. The first bills 50 of the 64-byte budget; the second cannot. + const cloneWorktreePath = vi.fn(async () => { + throw new Error('clonefile failed') + }) + + const skipped = await createWorktreeCopiedPaths(primary, worktree, ['one', 'two'], { + platform: 'darwin', + cloneWorktreePath, + copyBudget: TINY_BYTE_BUDGET + }) + + expect(readFileSync(join(worktree, 'one'), 'utf8')).toBe('x'.repeat(50)) + // No mayBePartial: a failed *file* clone publishes via link(2) from a temp + // path, so it never leaves anything at the target to go check. + expect(skipped).toEqual([{ path: 'two', reason: 'bytes' }]) + expect(existsSync(join(worktree, 'two'))).toBe(false) + }) + + posixIt('leaves link mode unbounded — symlinks cost no bytes', async () => { + mkdirSync(join(primary, 'node_modules')) + writeFileSync(join(primary, 'node_modules', 'pkg.js'), 'x'.repeat(200)) + + await createWorktreeLinkedPaths(primary, worktree, ['node_modules'], { + platform: 'linux', + copyBudget: TINY_BYTE_BUDGET + }) + + expect(existsSync(join(worktree, 'node_modules', 'pkg.js'))).toBe(true) + }) +}) diff --git a/src/main/ipc/worktree-include-copy-budget.ts b/src/main/ipc/worktree-include-copy-budget.ts new file mode 100644 index 00000000000..5f4243b1b68 --- /dev/null +++ b/src/main/ipc/worktree-include-copy-budget.ts @@ -0,0 +1,232 @@ +import { lstat, readdir } from 'node:fs/promises' +import { join } from 'node:path' + +/** Ceiling on what one worktree materialization may copy, measured before any + * bytes are written. Both limits are cumulative across the whole run, so a + * hundred medium entries trip the same guard one huge entry does. */ +export type WorktreeCopyBudget = { + maxBytes: number + maxEntries: number +} + +// Why: `.worktreeinclude` is a repo-authored list, and a repo that lists +// `node_modules` freezes worktree creation for minutes behind an inline copy +// (macOS gets a cheap APFS clone; Linux/Windows get a full `fs.cp`). These +// limits clear real payloads — `.env` files, `.vscode/`, small build caches — +// and refuse dependency trees. The entry limit matters as much as the byte +// limit: 200k tiny files are slow to copy even though they weigh little. +export const DEFAULT_WORKTREE_COPY_BUDGET: WorktreeCopyBudget = { + maxBytes: 2 * 1024 * 1024 * 1024, + maxEntries: 50_000 +} + +// Why: the sizing walk gets headroom over the copy budget so one refused +// `node_modules` cannot starve the small entries listed after it — it burns +// maxEntries+1 measuring, and without headroom nothing else would be sized. +const WORKTREE_COPY_SIZING_HEADROOM = 5 + +export type WorktreeCopyBudgetExceededReason = + | 'bytes' + | 'entries' + /** Not this entry's fault: earlier entries used up the total sizing walk. */ + | 'sizing' + +export type WorktreeCopySizeVerdict = + | { withinBudget: true; bytes: number; entries: number } + | { withinBudget: false; reason: WorktreeCopyBudgetExceededReason } + +export type SkippedWorktreeCopyPath = { + path: string + reason: WorktreeCopyBudgetExceededReason + /** The copy was abandoned after it had started, so leftovers may remain — + * "copy it in manually" would then merge into a half-populated directory. */ + mayBePartial?: boolean +} + +export type WorktreeCopyAdmitOptions = { + /** False when the backend clones copy-on-write (APFS `clonefile`), where + * bytes cost nothing and only inode count is real work. */ + bytesAreCopied?: boolean +} + +export type WorktreeCopyBudgetTracker = { + /** Measure `source` against what is left of the budget. A `withinBudget` + * verdict consumes the measured size; an over-budget verdict consumes + * nothing, so later, smaller entries still get their chance. + * + * Await each call before the next: the remaining pool is read before the + * measurement walk and written after it, so concurrent callers would both + * size against the same stale pool and could jointly bust the budget. */ + admit: (source: string, options?: WorktreeCopyAdmitOptions) => Promise + /** Bill bytes that were measured but not charged, because the copy was + * expected to clone and then didn't. Returns false if they no longer fit, + * in which case the caller must not run the copy. */ + chargeBytes: (bytes: number) => boolean +} + +type MeasuredCopySize = { + verdict: WorktreeCopySizeVerdict + /** Entries actually walked, whatever the verdict — this is the measurement's + * own cost, which the tracker charges so a long list of over-budget entries + * cannot re-freeze creation by re-walking for each one. */ + walked: number +} + +async function measureCopySize( + source: string, + remainingBytes: number, + remainingEntries: number, + remainingWalk: number +): Promise { + let bytes = 0 + let entries = 0 + const pending: string[] = [source] + while (pending.length > 0) { + const current = pending.pop() as string + let stats: Awaited> + try { + stats = await lstat(current) + } catch { + // Raced away between the walk and now — the copy will skip it too. + continue + } + entries += 1 + if (entries > Math.min(remainingEntries, remainingWalk)) { + // Why: attribute to whichever ceiling actually bound. Blaming the file + // limit for a walk that earlier entries used up would quote the user a + // limit this entry never approached. + const reason = remainingWalk < remainingEntries ? 'sizing' : 'entries' + return { verdict: { withinBudget: false, reason }, walked: entries } + } + // Why: both copy backends reproduce a nested symlink as a symlink rather + // than following it, so walking through one would double-count a shared + // target and could loop forever on a cycle. + if (stats.isSymbolicLink()) { + continue + } + if (stats.isDirectory()) { + try { + for (const name of await readdir(current)) { + pending.push(join(current, name)) + } + } catch { + // Unreadable directory — nothing measurable, and the copy will report it. + } + continue + } + bytes += stats.size + if (bytes > remainingBytes) { + return { verdict: { withinBudget: false, reason: 'bytes' }, walked: entries } + } + } + return { verdict: { withinBudget: true, bytes, entries }, walked: entries } +} + +/** Why a pre-measurement pass rather than aborting mid-copy: `fs.cp` ignores + * its `signal` option, so a copy that has started cannot be cancelled and + * would leave a partial tree behind. Refusing before the first byte is + * written keeps the worktree in a state the user can reason about. The walk + * is itself bounded — it returns the moment either limit is crossed. */ +export function createWorktreeCopyBudgetTracker( + budget: WorktreeCopyBudget = DEFAULT_WORKTREE_COPY_BUDGET +): WorktreeCopyBudgetTracker { + let remainingBytes = budget.maxBytes + let remainingEntries = budget.maxEntries + // Why: refused entries consume no copy budget, so without a separate ceiling + // on walking itself a `.worktreeinclude` listing 1000 over-budget directories + // would pay a fresh full-limit walk for each one — the very stall this bounds. + let remainingWalk = budget.maxEntries * WORKTREE_COPY_SIZING_HEADROOM + return { + admit: async (source, { bytesAreCopied = true } = {}) => { + if (remainingWalk <= 0) { + return { withinBudget: false, reason: 'sizing' } + } + const { verdict, walked } = await measureCopySize( + source, + bytesAreCopied ? remainingBytes : Number.POSITIVE_INFINITY, + remainingEntries, + remainingWalk + ) + remainingWalk -= walked + if (verdict.withinBudget) { + if (bytesAreCopied) { + remainingBytes -= verdict.bytes + } + remainingEntries -= verdict.entries + } + return verdict + }, + chargeBytes: (bytes) => { + if (bytes > remainingBytes) { + return false + } + remainingBytes -= bytes + return true + } + } +} + +function formatByteLimit(maxBytes: number): string { + const gigabytes = maxBytes / (1024 * 1024 * 1024) + if (gigabytes >= 1) { + return `${Number(gigabytes.toFixed(1))} GB` + } + return `${Math.max(1, Math.round(maxBytes / (1024 * 1024)))} MB` +} + +const MAX_NAMED_SKIPPED_ENTRIES = 5 + +/** User-facing warning for entries the budget refused. Returns undefined when + * nothing was skipped so callers can spread it conditionally. */ +export function formatWorktreeIncludeCopyWarning( + skipped: readonly SkippedWorktreeCopyPath[], + budget: WorktreeCopyBudget = DEFAULT_WORKTREE_COPY_BUDGET +): string | undefined { + if (skipped.length === 0) { + return undefined + } + // Why: `.worktreeinclude` allows 1000 entries and every one can be skipped, + // so enumerating them all would put a multi-kilobyte sentence in a warning. + const nameList = (entries: readonly SkippedWorktreeCopyPath[]): string => { + const shown = entries.slice(0, MAX_NAMED_SKIPPED_ENTRIES) + const names = shown.map((entry) => `"${entry.path}"`).join(', ') + const rest = entries.length - shown.length + return rest > 0 ? `${names} and ${rest.toLocaleString('en-US')} more` : names + } + const describe = (entries: readonly SkippedWorktreeCopyPath[]): string => { + const subject = entries.length === 1 ? 'entry' : 'entries' + const verb = entries.length === 1 ? 'was' : 'were' + return `.worktreeinclude ${subject} ${nameList(entries)} ${verb} not copied into the new workspace` + } + const pronoun = (count: number): string => (count === 1 ? 'it' : 'them') + // Why: an entry refused because earlier ones exhausted the sizing walk never + // approached the limits itself, so quoting them at the user would be a lie. + const overBudget = skipped.filter((entry) => entry.reason !== 'sizing') + const unsized = skipped.filter((entry) => entry.reason === 'sizing') + const sentences: string[] = [] + if (overBudget.length > 0) { + sentences.push( + `${describe(overBudget)}: copying ${pronoun(overBudget.length)} would exceed the ` + + `${formatByteLimit(budget.maxBytes)} / ${budget.maxEntries.toLocaleString('en-US')} ` + + `file limit that keeps workspace creation responsive.` + ) + } + const partial = skipped.filter((entry) => entry.mayBePartial) + if (unsized.length > 0) { + sentences.push( + `${describe(unsized)}: earlier entries used up the budget for measuring what to copy.` + ) + } + if (partial.length > 0) { + // Why: the copy was abandoned after it started, so "copy it in manually" + // would merge into whatever the interrupted run already left behind. + sentences.push( + `${nameList(partial)} may hold a partial copy from the interrupted attempt — check ` + + `${pronoun(partial.length)} before reusing this workspace.` + ) + } + sentences.push( + `Copy ${pronoun(skipped.length)} in manually if this workspace needs ${pronoun(skipped.length)}.` + ) + return sentences.join(' ') +} diff --git a/src/main/ipc/worktree-polling-scan-budget.test.ts b/src/main/ipc/worktree-polling-scan-budget.test.ts new file mode 100644 index 00000000000..02aca191ade --- /dev/null +++ b/src/main/ipc/worktree-polling-scan-budget.test.ts @@ -0,0 +1,287 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' +import { + WorktreePollingScanBudget, + type WorktreePollingCapacityError +} from './worktree-polling-scan-budget' + +describe('worktree polling scan budget', () => { + const cleanupPaths: string[] = [] + + afterEach(async () => { + await Promise.all( + cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) + }) + + async function createRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-worktree-poll-bounds-')) + cleanupPaths.push(root) + return root + } + + function makeTarget(kind: 'base' | 'git-common', path: string): WorktreeBaseWatchTarget { + return { + key: `${kind}:local:${path}`, + kind, + path, + repos: new Map([ + [ + 'repo-1', + { + repoId: 'repo-1', + repoName: 'project', + nestWorkspaces: false + } + ] + ]) + } + } + + it('accepts exact entry, path-count, and path-byte limits', () => { + const budget = new WorktreePollingScanBudget({ + maxScannedEntries: 2, + maxRetainedPaths: 2, + maxRetainedPathBytes: 6 + }) + + budget.claimEntry() + budget.claimEntry() + budget.claimRetainedPath('one') + expect(() => budget.claimRetainedPath('two')).not.toThrow() + }) + + it('rejects the first scanned entry over the limit', () => { + const budget = new WorktreePollingScanBudget({ maxScannedEntries: 1 }) + budget.claimEntry() + + expect(() => budget.claimEntry()).toThrow( + expect.objectContaining>({ + resource: 'scanned entries', + observed: 2, + limit: 1 + }) + ) + }) + + it('rejects the first retained path byte over the limit', () => { + const budget = new WorktreePollingScanBudget({ + maxRetainedPaths: 2, + maxRetainedPathBytes: 5 + }) + budget.claimRetainedPath('one') + + expect(() => budget.claimRetainedPath('two')).toThrow( + expect.objectContaining>({ + resource: 'retained path bytes', + observed: 6, + limit: 5 + }) + ) + }) + + it('accepts the exact retained repo budget and rejects one byte less', () => { + const exact = new WorktreePollingScanBudget({ + maxRepoConfigs: 1, + maxRetainedRepoBytes: 130 + }) + expect(() => exact.claimRepoConfig('a', 'b')).not.toThrow() + + const overflow = new WorktreePollingScanBudget({ + maxRepoConfigs: 1, + maxRetainedRepoBytes: 129 + }) + expect(() => overflow.claimRepoConfig('a', 'b')).toThrow( + expect.objectContaining>({ + resource: 'retained repo bytes', + observed: 130, + limit: 129 + }) + ) + }) + + it('rejects the first repo config over the count limit', () => { + const budget = new WorktreePollingScanBudget({ maxRepoConfigs: 1 }) + budget.claimRepoConfig('repo-1', 'one') + + expect(() => budget.claimRepoConfig('repo-2', 'two')).toThrow( + expect.objectContaining>({ + resource: 'repo configs', + observed: 2, + limit: 1 + }) + ) + }) + + it('starts a base poller at the exact directory-entry limit', async () => { + const root = await createRoot() + await Promise.all([mkdir(join(root, 'one')), mkdir(join(root, 'two'))]) + const target = makeTarget('base', root) + + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { + scanLimits: { + maxScannedEntries: 2, + maxRetainedPaths: 3 + } + } + ) + + await poller.unsubscribe() + }) + + it('rejects a base snapshot on the first directory entry over the limit', async () => { + const root = await createRoot() + await Promise.all([ + mkdir(join(root, 'one')), + mkdir(join(root, 'two')), + mkdir(join(root, 'three')) + ]) + const target = makeTarget('base', root) + + await expect( + startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { + scanLimits: { maxScannedEntries: 2 } + } + ) + ).rejects.toMatchObject({ resource: 'scanned entries', observed: 3, limit: 2 }) + }) + + it('keeps the last complete snapshot while over capacity and resumes afterward', async () => { + const root = await createRoot() + const knownWorktree = join(root, 'known') + await mkdir(knownWorktree) + await writeFile(join(knownWorktree, '.git'), 'gitdir: elsewhere') + const target = makeTarget('base', root) + const events: { type: string; path: string }[] = [] + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (nextEvents) => events.push(...nextEvents), + { + pollIntervalMs: 100, + scanLimits: { maxScannedEntries: 2 } + } + ) + + try { + await Promise.all([ + mkdir(join(root, 'overflow-a')), + mkdir(join(root, 'overflow-b')), + mkdir(join(root, 'overflow-c')) + ]) + await rm(knownWorktree, { recursive: true }) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(events).toEqual([]) + + await Promise.all([ + rm(join(root, 'overflow-b'), { recursive: true }), + rm(join(root, 'overflow-c'), { recursive: true }) + ]) + await vi.waitFor( + () => { + expect(events).toContainEqual({ type: 'delete', path: knownWorktree }) + }, + { timeout: 2_000 } + ) + } finally { + await poller.unsubscribe() + } + }) + + it('pauses snapshots for excess repo configs and resumes when they are removed', async () => { + const root = await createRoot() + const knownWorktree = join(root, 'known') + await mkdir(knownWorktree) + await writeFile(join(knownWorktree, '.git'), 'gitdir: elsewhere') + const target = makeTarget('base', root) + const events: { type: string; path: string }[] = [] + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (nextEvents) => events.push(...nextEvents), + { + pollIntervalMs: 100, + scanLimits: { maxRepoConfigs: 1 } + } + ) + + try { + target.repos.set('repo-2', { + repoId: 'repo-2', + repoName: 'second', + nestWorkspaces: false + }) + await rm(knownWorktree, { recursive: true }) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(events).toEqual([]) + + target.repos.delete('repo-2') + await vi.waitFor( + () => { + expect(events).toContainEqual({ type: 'delete', path: knownWorktree }) + }, + { timeout: 2_000 } + ) + } finally { + await poller.unsubscribe() + } + }) + + it('starts git-common polling at the exact linked-worktree limit', async () => { + const root = await createRoot() + await Promise.all([ + mkdir(join(root, 'worktrees', 'one'), { recursive: true }), + mkdir(join(root, 'worktrees', 'two'), { recursive: true }) + ]) + const target = makeTarget('git-common', root) + + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { + platform: 'linux', + scanLimits: { + maxScannedEntries: 2, + maxRetainedPaths: 2 + } + } + ) + + await poller.unsubscribe() + }) + + it('rejects git-common polling on the first linked worktree over the limit', async () => { + const root = await createRoot() + await Promise.all([ + mkdir(join(root, 'worktrees', 'one'), { recursive: true }), + mkdir(join(root, 'worktrees', 'two'), { recursive: true }), + mkdir(join(root, 'worktrees', 'three'), { recursive: true }) + ]) + const target = makeTarget('git-common', root) + + await expect( + startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { + platform: 'linux', + scanLimits: { maxScannedEntries: 2 } + } + ) + ).rejects.toMatchObject({ resource: 'scanned entries', observed: 3, limit: 2 }) + }) +}) diff --git a/src/main/ipc/worktree-polling-scan-budget.ts b/src/main/ipc/worktree-polling-scan-budget.ts new file mode 100644 index 00000000000..727b587ce41 --- /dev/null +++ b/src/main/ipc/worktree-polling-scan-budget.ts @@ -0,0 +1,128 @@ +export const WORKTREE_POLLING_MAX_SCANNED_ENTRIES = 100_000 +export const WORKTREE_POLLING_MAX_RETAINED_PATHS = 16_384 +export const WORKTREE_POLLING_MAX_RETAINED_PATH_BYTES = 16 * 1024 * 1024 +export const WORKTREE_POLLING_MAX_REPO_CONFIGS = 4_096 +export const WORKTREE_POLLING_MAX_RETAINED_REPO_BYTES = 4 * 1024 * 1024 + +export type WorktreePollingScanLimits = { + maxScannedEntries: number + maxRetainedPaths: number + maxRetainedPathBytes: number + maxRepoConfigs: number + maxRetainedRepoBytes: number +} + +export class WorktreePollingCapacityError extends Error { + constructor( + readonly resource: + | 'scanned entries' + | 'retained paths' + | 'retained path bytes' + | 'repo configs' + | 'retained repo bytes', + readonly observed: number, + readonly limit: number + ) { + super(`Worktree polling exceeded ${limit} ${resource} (observed ${observed})`) + this.name = 'WorktreePollingCapacityError' + } +} + +function resolveLimit(requested: number | undefined, maximum: number, name: string): number { + if (requested === undefined) { + return maximum + } + if (!Number.isSafeInteger(requested) || requested < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + return Math.min(requested, maximum) +} + +export class WorktreePollingScanBudget { + private scannedEntries = 0 + private retainedPaths = 0 + private retainedPathBytes = 0 + private repoConfigs = 0 + private retainedRepoBytes = 0 + private readonly limits: WorktreePollingScanLimits + + constructor(requested: Partial = {}) { + this.limits = { + maxScannedEntries: resolveLimit( + requested.maxScannedEntries, + WORKTREE_POLLING_MAX_SCANNED_ENTRIES, + 'maxScannedEntries' + ), + maxRetainedPaths: resolveLimit( + requested.maxRetainedPaths, + WORKTREE_POLLING_MAX_RETAINED_PATHS, + 'maxRetainedPaths' + ), + maxRetainedPathBytes: resolveLimit( + requested.maxRetainedPathBytes, + WORKTREE_POLLING_MAX_RETAINED_PATH_BYTES, + 'maxRetainedPathBytes' + ), + maxRepoConfigs: resolveLimit( + requested.maxRepoConfigs, + WORKTREE_POLLING_MAX_REPO_CONFIGS, + 'maxRepoConfigs' + ), + maxRetainedRepoBytes: resolveLimit( + requested.maxRetainedRepoBytes, + WORKTREE_POLLING_MAX_RETAINED_REPO_BYTES, + 'maxRetainedRepoBytes' + ) + } + } + + claimEntry(): void { + this.scannedEntries += 1 + if (this.scannedEntries > this.limits.maxScannedEntries) { + throw new WorktreePollingCapacityError( + 'scanned entries', + this.scannedEntries, + this.limits.maxScannedEntries + ) + } + } + + claimRetainedPath(path: string): void { + this.retainedPaths += 1 + if (this.retainedPaths > this.limits.maxRetainedPaths) { + throw new WorktreePollingCapacityError( + 'retained paths', + this.retainedPaths, + this.limits.maxRetainedPaths + ) + } + this.retainedPathBytes += Buffer.byteLength(path, 'utf8') + if (this.retainedPathBytes > this.limits.maxRetainedPathBytes) { + throw new WorktreePollingCapacityError( + 'retained path bytes', + this.retainedPathBytes, + this.limits.maxRetainedPathBytes + ) + } + } + + claimRepoConfig(repoId: string, repoName: string): void { + this.repoConfigs += 1 + if (this.repoConfigs > this.limits.maxRepoConfigs) { + throw new WorktreePollingCapacityError( + 'repo configs', + this.repoConfigs, + this.limits.maxRepoConfigs + ) + } + this.retainedRepoBytes += + Buffer.byteLength(repoId, 'utf8') + Buffer.byteLength(repoName, 'utf8') + 128 + if (this.retainedRepoBytes > this.limits.maxRetainedRepoBytes) { + throw new WorktreePollingCapacityError( + 'retained repo bytes', + this.retainedRepoBytes, + this.limits.maxRetainedRepoBytes + ) + } + } +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 707929ee7a2..c146acd62f0 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -57,7 +57,9 @@ import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { MAX_ORCA_YAML_BYTES, MAX_ORCA_YAML_CODE_UNITS } from '../../shared/orca-yaml-file-limit' import { getSshGitUsername } from '../git/git-username' +import { readFilesystemProviderBoundedText } from '../filesystem-provider-bounded-text' import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' import { registerOptionalSshWorktreeCreateRoots, @@ -96,6 +98,7 @@ import { } from './worktree-push-target-setup' import { isENOENT, registerWorktreeRootsForRepo } from './filesystem-auth' import { createWorktreeCopiedPaths, createWorktreeLinkedPaths } from './worktree-symlinks' +import { formatWorktreeIncludeCopyWarning } from './worktree-include-copy-budget' import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' @@ -1188,8 +1191,12 @@ async function readRemoteOrcaYaml( hooksRootPath: string ): Promise> { try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(hooksRootPath, 'orca.yaml')) - return result.isBinary ? null : parseOrcaYaml(result.content) + const result = await readFilesystemProviderBoundedText( + fsProvider, + joinWorktreeRelativePath(hooksRootPath, 'orca.yaml'), + { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } + ) + return result.kind === 'text' ? parseOrcaYaml(result.content) : null } catch { return null } @@ -2466,9 +2473,18 @@ export async function createLocalWorktree( const includePaths = await timing.time('resolve_worktreeinclude', () => resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions) ) + let includeCopyWarning: string | undefined if (includePaths.length > 0) { await timing.time('copy_worktreeinclude', async () => { - await createWorktreeCopiedPaths(repo.path, created.path, includePaths) + const skippedIncludePaths = await createWorktreeCopiedPaths( + repo.path, + created.path, + includePaths + ) + includeCopyWarning = formatWorktreeIncludeCopyWarning(skippedIncludePaths) + if (includeCopyWarning) { + console.warn(`[worktree-include] ${includeCopyWarning}`) + } }) } @@ -2542,7 +2558,11 @@ export async function createLocalWorktree( ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } : {}), ...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}), - ...(stagedStartup.warning ? { warning: stagedStartup.warning } : {}), + ...(stagedStartup.warning + ? { warning: appendWorktreeCreateWarning(includeCopyWarning, stagedStartup.warning) } + : includeCopyWarning + ? { warning: includeCopyWarning } + : {}), timing: timing.finish() } } diff --git a/src/main/ipc/worktree-symlink-reconciliation.real.test.ts b/src/main/ipc/worktree-symlink-reconciliation.real.test.ts deleted file mode 100644 index 1c48934b607..00000000000 --- a/src/main/ipc/worktree-symlink-reconciliation.real.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { findCreatedWorktree } from './created-worktree-reconciliation' -import { areWorktreePathsEqual } from './worktree-path-comparison' - -type ListedWorktree = { path: string; branch?: string } - -const fixtureRoots: string[] = [] - -afterEach(async () => { - await Promise.all( - fixtureRoots.splice(0).map((root) => rm(root, { force: true, recursive: true })) - ) -}) - -describe('native worktree symlink reconciliation (real Git)', () => { - it('matches the authoritative listed row after adding through a symlink root', async () => { - const fixtureRoot = await mkdtemp(join(process.cwd(), '.pr-10172-real-git-')) - fixtureRoots.push(fixtureRoot) - const repoPath = join(fixtureRoot, 'repo') - const canonicalRoot = join(fixtureRoot, 'canonical-worktrees') - const aliasRoot = join(fixtureRoot, 'visible-worktrees') - const stalePath = join(aliasRoot, 'aaa-stale') - const requestedPath = join(aliasRoot, 'feature') - await mkdir(repoPath) - await mkdir(canonicalRoot) - await symlink(canonicalRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir') - - git(repoPath, ['init', '--quiet']) - git(repoPath, ['config', 'user.email', 'review@example.invalid']) - git(repoPath, ['config', 'user.name', 'PR review']) - await writeFile(join(repoPath, 'README.md'), 'fixture\n') - git(repoPath, ['add', 'README.md']) - git(repoPath, ['commit', '--quiet', '-m', 'fixture']) - git(repoPath, [ - '-c', - 'maintenance.auto=false', - 'worktree', - 'add', - '--quiet', - '-b', - 'stale', - stalePath, - 'HEAD' - ]) - await rm(stalePath, { force: true, recursive: true }) - git(repoPath, [ - '-c', - 'maintenance.auto=false', - 'worktree', - 'add', - '--quiet', - '-b', - 'feature', - requestedPath, - 'HEAD' - ]) - - const listedRows = parseListedWorktrees(git(repoPath, ['worktree', 'list', '--porcelain'])) - const listed = listedRows.find((worktree) => worktree.branch === 'refs/heads/feature') - if (!listed) { - throw new Error('Created worktree missing from Git listing') - } - const staleIndex = listedRows.findIndex((worktree) => worktree.branch === 'refs/heads/stale') - const createdIndex = listedRows.indexOf(listed) - expect(staleIndex).toBeGreaterThanOrEqual(0) - expect(createdIndex).toBeGreaterThan(staleIndex) - expect(await realpath(listed.path)).toBe(await realpath(requestedPath)) - if (process.platform !== 'win32') { - expect(listed.path).toBe(join(await realpath(canonicalRoot), 'feature')) - expect(areWorktreePathsEqual(listed.path, requestedPath)).toBe(false) - } - expect(findCreatedWorktree(listedRows, requestedPath, 'feature')).toBe(listed) - }) -}) - -function git(repoPath: string, args: string[]): string { - return execFileSync('git', ['-C', repoPath, ...args], { encoding: 'utf8' }) -} - -function parseListedWorktrees(output: string): ListedWorktree[] { - return output - .trim() - .split('\n\n') - .map((block) => { - const pathLine = block.split('\n').find((line) => line.startsWith('worktree ')) - const branchLine = block.split('\n').find((line) => line.startsWith('branch ')) - if (!pathLine) { - throw new Error(`Malformed Git worktree listing:\n${output}`) - } - return { - path: pathLine.slice('worktree '.length), - ...(branchLine ? { branch: branchLine.slice('branch '.length) } : {}) - } - }) -} diff --git a/src/main/ipc/worktree-symlinks.ts b/src/main/ipc/worktree-symlinks.ts index fb81cc37df4..03e6e60b26b 100644 --- a/src/main/ipc/worktree-symlinks.ts +++ b/src/main/ipc/worktree-symlinks.ts @@ -2,17 +2,26 @@ import { symlink, mkdir, stat, lstat, unlink, cp, realpath } from 'node:fs/promi import { dirname, isAbsolute, resolve } from 'node:path' import { ApfsCloneUnavailableError, + canCloneWithApfs, cloneWorktreePathWithApfs, defaultApfsCloneDeps, WorktreeLinkedPathTargetExistsError, type ApfsCloneDeps, type DarwinFilesystemCache } from './worktree-apfs-clone' +import { + createWorktreeCopyBudgetTracker, + type SkippedWorktreeCopyPath, + type WorktreeCopyBudget +} from './worktree-include-copy-budget' type WorktreeLinkedPathOptions = { platform?: NodeJS.Platform cloneWorktreePath?: (source: string, target: string, sourceIsDirectory: boolean) => Promise apfsCloneDeps?: ApfsCloneDeps + /** Copy-mode only. Overridable so tests can trip the bound without writing + * gigabytes to disk. */ + copyBudget?: WorktreeCopyBudget } // 'link': symlink when APFS clone is unavailable (user-configured shared paths). @@ -64,20 +73,26 @@ async function copyWorktreePath(source: string, target: string): Promise { await cp(source, target, { recursive: true, force: false, errorOnExist: false }) } +/** An APFS clone was expected (so its bytes were never charged) but failed, and + * the byte-for-byte fallback would escape the budget. */ +class WorktreeCopyBudgetFallbackError extends Error { + constructor(target: string) { + super(`APFS clone failed and a real copy of "${target}" would exceed the copy budget`) + this.name = 'WorktreeCopyBudgetFallbackError' + } +} + async function createWorktreeLinkedPath( source: string, + copySource: string, target: string, sourceIsDirectory: boolean, sourceIsSymbolicLink: boolean, mode: WorktreeMaterializeMode, options: WorktreeLinkedPathOptions, - apfsFilesystemCache: DarwinFilesystemCache + apfsFilesystemCache: DarwinFilesystemCache, + realCopyFallbackAllowed: () => boolean ): Promise { - // Why: copy mode promises each worktree an independent copy; copying the - // symlink itself would recreate a link to the shared target, so edits in the - // worktree would leak back into the primary checkout (or escape it entirely if - // the link points outside). Resolve the real source so we copy content. - const copySource = mode === 'copy' && sourceIsSymbolicLink ? await realpath(source) : source if (options.platform === 'darwin' && (!sourceIsSymbolicLink || mode === 'copy')) { try { const cloneWorktreePath = @@ -101,6 +116,13 @@ async function createWorktreeLinkedPath( // appeared after our preflight. if (!(error instanceof ApfsCloneUnavailableError)) { console.warn(`[worktree-symlinks] APFS clone-copy unavailable for "${target}":`, error) + // Why: the fallback is a real byte-for-byte copy. If this entry was + // admitted as a free clone its bytes were never charged, so bill them + // now — and refuse if they no longer fit, rather than silently + // reopening the unbounded copy this budget exists to close. + if (mode === 'copy' && !realCopyFallbackAllowed()) { + throw new WorktreeCopyBudgetFallbackError(target) + } } } } @@ -111,6 +133,30 @@ async function createWorktreeLinkedPath( await symlinkWorktreePath(source, target, sourceIsDirectory) } +/** Whether this copy will land as an APFS clone rather than a byte-for-byte + * copy. Only the volume probe can answer it, and that probe writes nothing. */ +async function copyIsCopyOnWrite( + source: string, + worktreePath: string, + options: WorktreeLinkedPathOptions, + apfsFilesystemCache: DarwinFilesystemCache +): Promise { + if (options.platform !== 'darwin') { + return false + } + // An injected clone stands in for the real one, so treat it as cloning — + // probing the real filesystem here would make these tests host-dependent. + if (options.cloneWorktreePath) { + return true + } + return await canCloneWithApfs( + source, + worktreePath, + options.apfsCloneDeps ?? defaultApfsCloneDeps, + apfsFilesystemCache + ) +} + async function targetExists(target: string): Promise { try { // Why: lstat so a pre-existing symlink (even a broken one) is detected and @@ -128,11 +174,15 @@ async function materializeWorktreePaths( paths: readonly string[], mode: WorktreeMaterializeMode, options: WorktreeLinkedPathOptions = {} -): Promise { +): Promise { const effectiveOptions = { platform: process.platform, ...options } // Why: one df+diskutil probe per distinct volume for the whole materialization, // not per copied path — see DarwinFilesystemCache. const apfsFilesystemCache: DarwinFilesystemCache = new Map() + // Why: one budget for the whole materialization, so a hundred medium entries + // are refused for the same reason one `node_modules` entry is. + const copyBudget = createWorktreeCopyBudgetTracker(options.copyBudget) + const skipped: SkippedWorktreeCopyPath[] = [] for (const rawPath of paths) { const safePath = getSafeRelativePath(rawPath) @@ -163,23 +213,77 @@ async function materializeWorktreePaths( continue } + // Why: copy mode promises each worktree an independent copy; copying the + // symlink itself would recreate a link to the shared target, so edits in the + // worktree would leak back into the primary checkout (or escape it entirely if + // the link points outside). Resolve the real source so we copy content. + let copySource = source + let bytesAreCopied = true + let measuredBytes = 0 + if (mode === 'copy') { + try { + if (sourceIsSymbolicLink) { + copySource = await realpath(source) + } + // Why: an APFS clone is copy-on-write — a 2.7 GB tree clones in ~20ms + // and consumes no disk — so bytes are not the cost there, inodes are. + // Charging bytes on that path would refuse work that is already free. + bytesAreCopied = !(await copyIsCopyOnWrite( + copySource, + worktreePath, + effectiveOptions, + apfsFilesystemCache + )) + const verdict = await copyBudget.admit(copySource, { bytesAreCopied }) + if (!verdict.withinBudget) { + // Why: refuse before the first byte is written. Aborting mid-copy is + // not available (`fs.cp` ignores its `signal`) and would strand a + // partial tree; the caller surfaces this as a create warning. + skipped.push({ path: safePath.rel, reason: verdict.reason }) + console.warn( + `[worktree-symlinks] Skipping "${safePath.rel}": copy exceeds the worktree copy budget (${verdict.reason})` + ) + continue + } + measuredBytes = verdict.bytes + } catch (error) { + console.error(`[worktree-symlinks] Failed to size "${safePath.rel}" (${source}):`, error) + continue + } + } + try { await createWorktreeLinkedPath( source, + copySource, target, sourceIsDirectory, sourceIsSymbolicLink, mode, effectiveOptions, - apfsFilesystemCache + apfsFilesystemCache, + () => bytesAreCopied || copyBudget.chargeBytes(measuredBytes) ) } catch (error) { + if (error instanceof WorktreeCopyBudgetFallbackError) { + // Why: a directory clone reserves the target and only removes it when + // it is still *empty*, so leftovers can survive. A file clone publishes + // from a temp path with link(2), so a failure leaves nothing behind. + skipped.push({ + path: safePath.rel, + reason: 'bytes', + ...(sourceIsDirectory ? { mayBePartial: true } : {}) + }) + console.warn(`[worktree-symlinks] Skipping "${safePath.rel}": ${error.message}`) + continue + } console.error( `[worktree-symlinks] Failed to link "${safePath.rel}" (${source} -> ${target}):`, error ) } } + return skipped } export async function createWorktreeLinkedPaths( @@ -194,14 +298,18 @@ export async function createWorktreeLinkedPaths( /** Copy `.worktreeinclude`-resolved paths from the primary checkout into a * freshly-created worktree. Same per-path failure isolation as * createWorktreeLinkedPaths, but the non-APFS fallback is a real copy, never a - * symlink: the convention promises each worktree its own private copy. */ + * symlink: the convention promises each worktree its own private copy. + * + * Returns the entries refused by the copy budget so worktree creation can + * surface them — a workspace quietly missing its included files is worse than + * one that says which entries it left behind. */ export async function createWorktreeCopiedPaths( primaryPath: string, worktreePath: string, paths: readonly string[], options: WorktreeLinkedPathOptions = {} -): Promise { - await materializeWorktreePaths(primaryPath, worktreePath, paths, 'copy', options) +): Promise { + return await materializeWorktreePaths(primaryPath, worktreePath, paths, 'copy', options) } /** Create filesystem symlinks from the primary checkout into a freshly-created diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 67b197e91a1..de161934892 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -156,6 +156,7 @@ vi.mock('../source-control/hosted-review', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProviderGeneration: () => 0, getSshGitProvider: getSshGitProviderMock, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped. Click Reconnect on the SSH target before retrying.', @@ -2037,7 +2038,16 @@ describe('registerWorktreeHandlers', () => { ) }) - it('returns the PR head push target when resolving a fork PR base', async () => { + it('threads explicit origin preference into dual-remote PR head resolution', async () => { + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + issueSourcePreference: 'origin', + worktreeBaseRef: null + }) getPullRequestPushTargetMock.mockResolvedValue({ pushTarget: { remoteName: 'pr-prateek-orca', @@ -2047,7 +2057,12 @@ describe('registerWorktreeHandlers', () => { }) gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: `${ORIGIN_REMOTE_URL}\n`, stderr: '' } + const url = + args[2] === 'origin' ? ORIGIN_REMOTE_URL : 'git@github.com:org/upstream-repo.git' + return { stdout: `${url}\n`, stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\nupstream\n', stderr: '' } } if (args[0] === 'rev-parse') { return { stdout: 'abc123\n', stderr: '' } @@ -2071,6 +2086,17 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/repo', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } ) + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( + ['remote', 'get-url', 'upstream'], + expect.anything() + ) + expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( + '/workspace/repo', + 1738, + null, + {}, + 'origin' + ) expect(result).toMatchObject({ baseBranch: 'abc123', headSha: 'abc123', @@ -3842,6 +3868,12 @@ describe('registerWorktreeHandlers', () => { ]) } const fsProvider = { + stat: vi.fn(async (filePath: string) => { + if (filePath.endsWith('orca.yaml')) { + return { size: 32, type: 'file', mtime: 0 } + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n setup: pnpm install\n', isBinary: false diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index ccbf77951b4..22aec867378 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1341,16 +1341,18 @@ export function registerWorktreeHandlers( headRefName: args.headRefName, baseRefName: args.baseRefName, isCrossRepository: args.isCrossRepository, + issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), gitExec, fetchRemoteTrackingRef, fetchPullRequestHeadRef, - // Why: one shared resolver for local and SSH so origin-vs-upstream - // cannot diverge by surface; it prefers the remote hosting the PR's project. + // Why: one resolver keeps source preference and hosting identity aligned + // across local, WSL, and SSH worktree creation. resolveRemote: () => resolveGitHubReviewHeadRemote({ repoPath: repo.path, + issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), gitExec diff --git a/src/main/ipc/wsl-watcher-snapshot.test.ts b/src/main/ipc/wsl-watcher-snapshot.test.ts new file mode 100644 index 00000000000..ff80833ce7f --- /dev/null +++ b/src/main/ipc/wsl-watcher-snapshot.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { MAX_WSL_SNAPSHOT_RETAINED_ENTRIES, parseWslSnapshotFrame } from './wsl-watcher-snapshot' + +describe('parseWslSnapshotFrame', () => { + it('rejects a snapshot with more retained paths than the entry budget', () => { + const records = Array.from( + { length: MAX_WSL_SNAPSHOT_RETAINED_ENTRIES + 1 }, + (_, index) => `f\t1\t/repo/file-${index}\0` + ).join('') + + expect(parseWslSnapshotFrame(records, 'Ubuntu')).toBeNull() + }) + + it('does not charge duplicate path updates as distinct retained entries', () => { + const records = Array.from( + { length: MAX_WSL_SNAPSHOT_RETAINED_ENTRIES + 1 }, + (_, index) => `f\t${index}\t/repo/same\0` + ).join('') + + const snapshot = parseWslSnapshotFrame(records, 'Ubuntu') + expect(snapshot).not.toBeNull() + expect(snapshot?.size).toBe(1) + expect(snapshot?.get('\\\\wsl.localhost\\Ubuntu\\repo\\same')?.mtime).toBe( + String(MAX_WSL_SNAPSHOT_RETAINED_ENTRIES) + ) + }) +}) diff --git a/src/main/ipc/wsl-watcher-snapshot.ts b/src/main/ipc/wsl-watcher-snapshot.ts new file mode 100644 index 00000000000..c1192d77de7 --- /dev/null +++ b/src/main/ipc/wsl-watcher-snapshot.ts @@ -0,0 +1,72 @@ +import type { Event as WatcherEvent } from '@parcel/watcher' +import { iterateNulDelimitedFields } from '../../shared/nul-delimited-fields' + +export const MAX_WSL_SNAPSHOT_RETAINED_ENTRIES = 50_000 + +export type WslSnapshotEntry = { + path: string + type: string + mtime: string +} + +export type WslSnapshot = Map + +function toWslUncPath(linuxPath: string, distro: string): string { + return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` +} + +export function parseWslSnapshotFrame(frame: string, distro: string): WslSnapshot | null { + const snapshot: WslSnapshot = new Map() + for (const rawEntry of iterateNulDelimitedFields(frame)) { + if (!rawEntry) { + continue + } + const firstTab = rawEntry.indexOf('\t') + const secondTab = firstTab === -1 ? -1 : rawEntry.indexOf('\t', firstTab + 1) + if (firstTab <= 0 || secondTab <= firstTab + 1) { + continue + } + const linuxPath = rawEntry.slice(secondTab + 1) + if (!linuxPath.startsWith('/')) { + continue + } + const path = toWslUncPath(linuxPath, distro) + if (!snapshot.has(path) && snapshot.size >= MAX_WSL_SNAPSHOT_RETAINED_ENTRIES) { + return null + } + snapshot.set(path, { + type: rawEntry.slice(0, firstTab), + mtime: rawEntry.slice(firstTab + 1, secondTab), + path + }) + } + return snapshot +} + +export function diffWslSnapshots(prev: WslSnapshot, next: WslSnapshot): WatcherEvent[] { + const events: WatcherEvent[] = [] + + for (const [entryPath, nextEntry] of next) { + const prevEntry = prev.get(entryPath) + if (!prevEntry) { + events.push({ type: 'create', path: entryPath } as WatcherEvent) + continue + } + if (prevEntry.type !== nextEntry.type) { + events.push({ type: 'delete', path: entryPath } as WatcherEvent) + events.push({ type: 'create', path: entryPath } as WatcherEvent) + continue + } + if (prevEntry.mtime !== nextEntry.mtime) { + events.push({ type: 'update', path: entryPath } as WatcherEvent) + } + } + + for (const entryPath of prev.keys()) { + if (!next.has(entryPath)) { + events.push({ type: 'delete', path: entryPath } as WatcherEvent) + } + } + + return events +} diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index 0c0ee11511c..71893f71d1d 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -3,6 +3,10 @@ import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + MAX_INTEGRATION_ACCOUNTS, + MAX_INTEGRATION_CREDENTIAL_BYTES +} from '../integration-account-persistence-limits' const OLD_FETCH = globalThis.fetch const { closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock } = vi.hoisted( @@ -650,4 +654,73 @@ describe('Jira client credential storage', () => { expect(headers.get('User-Agent')).toBe('Orca') expect(fetchMock).not.toHaveBeenCalled() }) + + it('admits the exact saved-site boundary without changing order', async () => { + const sites = Array.from({ length: MAX_INTEGRATION_ACCOUNTS }, (_, index) => ({ + id: `site-${index}`, + token: `token-${index}` + })) + writeMultiSiteFiles(sites, 'all') + const jira = await loadClientModule() + + const status = jira.getStatus() + expect(status.sites).toHaveLength(MAX_INTEGRATION_ACCOUNTS) + expect(status.sites?.map((site) => site.id)).toEqual(sites.map((site) => site.id)) + }) + + it('preserves an over-limit saved-site file and refuses to overwrite it', async () => { + const sites = Array.from({ length: MAX_INTEGRATION_ACCOUNTS + 1 }, (_, index) => ({ + id: `site-${index}`, + token: `token-${index}` + })) + writeMultiSiteFiles(sites, 'all') + const path = join(tempHome, '.orca', 'jira-sites.json') + const before = readFileSync(path, 'utf8') + const jira = await loadClientModule() + + expect(jira.getStatus()).toMatchObject({ connected: false, sites: [] }) + await expect( + jira.connect({ + siteUrl: 'example.atlassian.net', + email: 'ada@example.com', + apiToken: 'token-alpha' + }) + ).resolves.toMatchObject({ ok: false, error: expect.stringContaining('left unchanged') }) + expect(netFetchMock).not.toHaveBeenCalled() + expect(readFileSync(path, 'utf8')).toBe(before) + }) + + it('admits an exact-size Jira credential and rejects credential byte +1 before fetch', async () => { + const exactToken = 't'.repeat(MAX_INTEGRATION_CREDENTIAL_BYTES) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + accountId: 'account-alpha', + displayName: 'Ada', + emailAddress: 'ada@example.com' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'example.atlassian.net', + email: 'ada@example.com', + apiToken: exactToken + }) + ).resolves.toMatchObject({ ok: true }) + await expect( + jira.connect({ + siteUrl: 'another.atlassian.net', + email: 'ada@example.com', + apiToken: `${exactToken}t` + }) + ).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining(`${MAX_INTEGRATION_CREDENTIAL_BYTES} UTF-8 bytes`) + }) + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index 4c182ee2ad5..fa5e2ad8a47 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -2,17 +2,36 @@ request plumbing share one boundary so encrypted token lifecycle and multi-site selection cannot drift between task operations. */ import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { net, safeStorage, session } from 'electron' import { CredentialDecryptionError, credentialFileHasContent, + readIntegrationCredentialFileSync, + readIntegrationCredentialFileSyncText, readStoredCredentialToken } from '../integration-credential-file' import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { withSpan } from '../observability/tracer' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' +import { IntegrationApiConcurrencyGate } from '../integration-api-concurrency' +import { + assertIntegrationAccountCount, + assertIntegrationCredentialBytes, + assertIntegrationStringBytes, + IntegrationAccountPersistenceLimitError, + MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES, + MAX_INTEGRATION_ACCOUNT_FILE_BYTES, + MAX_INTEGRATION_ACCOUNT_ID_BYTES, + MAX_INTEGRATION_ACCOUNT_LABEL_BYTES, + MAX_INTEGRATION_ACCOUNTS, + MAX_INTEGRATION_ACCOUNT_URL_BYTES, + serializeIntegrationAccountFile, + unreadableIntegrationAccountFileError +} from '../integration-account-persistence-limits' +import { boundedIntegrationErrorMessage } from '../integration-error-message' import type { JiraAuthType, JiraConnectArgs, @@ -30,28 +49,14 @@ import type { const JIRA_API_USER_AGENT = 'Orca' const MAX_CONCURRENT = 4 -let running = 0 -const queue: (() => void)[] = [] +const concurrencyGate = new IntegrationApiConcurrencyGate(MAX_CONCURRENT) export function acquire(): Promise { - if (running < MAX_CONCURRENT) { - running += 1 - return Promise.resolve() - } - return new Promise((resolve) => - queue.push(() => { - running += 1 - resolve() - }) - ) + return concurrencyGate.acquire() } export function release(): void { - running -= 1 - const next = queue.shift() - if (next) { - next() - } + concurrencyGate.release() } type JiraSiteFile = { @@ -77,18 +82,29 @@ export class JiraApiError extends Error { status: number | null constructor(message: string, status: number | null = null) { - super(message) + super(boundedIntegrationErrorMessage(message)) this.status = status } } let cachedSiteFile: JiraSiteFile | null = null let siteFileLoaded = false +let siteFileReadError: Error | null = null const cachedTokens = new Map() // Why: decrypt failures are recorded per site so getStatus can explain // failing reads without re-touching the keychain on every status poll. const credentialErrors = new Map() +function cacheToken(siteId: string, token: string): void { + if (!cachedTokens.has(siteId) && cachedTokens.size >= MAX_INTEGRATION_ACCOUNTS) { + const oldestSiteId = cachedTokens.keys().next().value + if (oldestSiteId !== undefined) { + cachedTokens.delete(oldestSiteId) + } + } + cachedTokens.set(siteId, token) +} + function getOrcaDir(): string { return join(homedir(), '.orca') } @@ -102,6 +118,7 @@ function getTokenDir(): string { } function getTokenPath(siteId: string): string { + assertIntegrationStringBytes('Jira', 'site ID', siteId, MAX_INTEGRATION_ACCOUNT_ID_BYTES) return join(getTokenDir(), `${Buffer.from(siteId).toString('base64url')}.enc`) } @@ -157,19 +174,95 @@ function normalizeSite(input: unknown): JiraSite | null { } } +function assertSiteBounds(site: JiraSite): void { + assertIntegrationStringBytes('Jira', 'site ID', site.id, MAX_INTEGRATION_ACCOUNT_ID_BYTES) + assertIntegrationStringBytes('Jira', 'site URL', site.siteUrl, MAX_INTEGRATION_ACCOUNT_URL_BYTES) + assertIntegrationStringBytes('Jira', 'email', site.email, MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES) + assertIntegrationStringBytes( + 'Jira', + 'display name', + site.displayName, + MAX_INTEGRATION_ACCOUNT_LABEL_BYTES + ) + assertIntegrationStringBytes( + 'Jira', + 'account ID', + site.accountId, + MAX_INTEGRATION_ACCOUNT_LABEL_BYTES + ) +} + +function assertStoredSiteBounds(input: unknown): void { + if (!input || typeof input !== 'object') { + return + } + const record = input as Record + const fields = [ + ['site ID', record.id, MAX_INTEGRATION_ACCOUNT_ID_BYTES], + ['site URL', record.siteUrl, MAX_INTEGRATION_ACCOUNT_URL_BYTES], + ['email', record.email, MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES], + ['display name', record.displayName, MAX_INTEGRATION_ACCOUNT_LABEL_BYTES], + ['account ID', record.accountId, MAX_INTEGRATION_ACCOUNT_LABEL_BYTES] + ] as const + for (const [field, value, maxBytes] of fields) { + if (typeof value === 'string') { + assertIntegrationStringBytes('Jira', field, value, maxBytes) + } + } +} + +function assertSiteFileBounds(file: JiraSiteFile): void { + assertIntegrationAccountCount('Jira', file.sites.length) + for (const site of file.sites) { + assertSiteBounds(site) + } +} + function readSiteFileFromDisk(): JiraSiteFile { const path = getSiteFilePath() if (!existsSync(path)) { + siteFileReadError = null return emptySiteFile() } try { - const parsed = JSON.parse(readFileSync(path, { encoding: 'utf-8' })) as Partial - const sites = Array.isArray(parsed.sites) - ? parsed.sites - .map((site) => normalizeSite(site)) - .filter((site): site is JiraSite => site !== null) - .filter((site) => hasStoredToken(site.id)) - : [] + const parsed = JSON.parse(readIntegrationCredentialFileSyncText(path)) as Partial + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + parsed.version !== 1 || + !Array.isArray(parsed.sites) + ) { + throw unreadableIntegrationAccountFileError('Jira') + } + if (typeof parsed.activeSiteId === 'string') { + assertIntegrationStringBytes( + 'Jira', + 'active site ID', + parsed.activeSiteId, + MAX_INTEGRATION_ACCOUNT_ID_BYTES + ) + } + if (typeof parsed.selectedSiteId === 'string' && parsed.selectedSiteId !== 'all') { + assertIntegrationStringBytes( + 'Jira', + 'selected site ID', + parsed.selectedSiteId, + MAX_INTEGRATION_ACCOUNT_ID_BYTES + ) + } + const sites: JiraSite[] = [] + assertIntegrationAccountCount('Jira', parsed.sites.length) + for (const input of parsed.sites) { + assertStoredSiteBounds(input) + const site = normalizeSite(input) + if (!site) { + throw unreadableIntegrationAccountFileError('Jira') + } + if (hasStoredToken(site.id)) { + sites.push(site) + } + } const activeSiteId = typeof parsed.activeSiteId === 'string' && sites.some((site) => site.id === parsed.activeSiteId) @@ -181,8 +274,10 @@ function readSiteFileFromDisk(): JiraSiteFile { sites.some((site) => site.id === parsed.selectedSiteId)) ? parsed.selectedSiteId : activeSiteId + siteFileReadError = null return { version: 1, activeSiteId, selectedSiteId, sites } } catch { + siteFileReadError = unreadableIntegrationAccountFileError('Jira') return emptySiteFile() } } @@ -196,6 +291,10 @@ function getSiteFile(): JiraSiteFile { } function writeSiteFile(file: JiraSiteFile): void { + if (siteFileReadError) { + throw siteFileReadError + } + assertSiteFileBounds(file) ensureOrcaDir() const sites = file.sites.filter((site) => hasStoredToken(site.id)) const activeSiteId = @@ -209,22 +308,31 @@ function writeSiteFile(file: JiraSiteFile): void { ? file.selectedSiteId : activeSiteId - cachedSiteFile = { + const nextFile: JiraSiteFile = { version: 1, activeSiteId, selectedSiteId, sites } - siteFileLoaded = true - writeFileSync(getSiteFilePath(), JSON.stringify(cachedSiteFile, null, 2), { + const serialized = serializeIntegrationAccountFile(nextFile) + writeFileSync(getSiteFilePath(), serialized, { encoding: 'utf-8', mode: 0o600 }) + cachedSiteFile = nextFile + siteFileLoaded = true } function writeEncryptedToken(path: string, apiToken: string): void { + assertIntegrationCredentialBytes('Jira', apiToken) if (safeStorage.isEncryptionAvailable()) { - writeFileSync(path, safeStorage.encryptString(apiToken), { mode: 0o600 }) + const encrypted = safeStorage.encryptString(apiToken) + if (encrypted.length > MAX_INTEGRATION_ACCOUNT_FILE_BYTES) { + throw new IntegrationAccountPersistenceLimitError( + `Jira encrypted credential exceeds ${MAX_INTEGRATION_ACCOUNT_FILE_BYTES} bytes.` + ) + } + writeFileSync(path, encrypted, { mode: 0o600 }) return } console.warn('[jira] safeStorage encryption unavailable — storing token in plaintext') @@ -241,15 +349,19 @@ function readToken(siteId: string): string | null { return null } try { - const raw = readFileSync(path) + const raw = readIntegrationCredentialFileSync(path) const token = readStoredCredentialToken('Jira', raw) if (token) { - cachedTokens.set(siteId, token) + assertIntegrationCredentialBytes('Jira', token) + cacheToken(siteId, token) } credentialErrors.delete(siteId) return token } catch (error) { - if (error instanceof CredentialDecryptionError) { + if ( + error instanceof CredentialDecryptionError || + error instanceof IntegrationAccountPersistenceLimitError + ) { credentialErrors.set(siteId, error.message) throw error } @@ -261,7 +373,7 @@ function saveToken(siteId: string, apiToken: string): void { ensureOrcaDir() ensureTokenDir() writeEncryptedToken(getTokenPath(siteId), apiToken) - cachedTokens.set(siteId, apiToken) + cacheToken(siteId, apiToken) credentialErrors.delete(siteId) } @@ -344,9 +456,9 @@ function describeErrorCause(error: unknown): string | undefined { } const cause = (error as { cause?: unknown }).cause if (cause instanceof Error) { - return `${cause.name}: ${cause.message}` + return boundedIntegrationErrorMessage(`${cause.name}: ${cause.message}`) } - return cause === undefined ? undefined : String(cause) + return cause === undefined ? undefined : boundedIntegrationErrorMessage(cause) } async function jiraFetch(url: string, init: RequestInit): Promise { @@ -360,7 +472,7 @@ async function jiraFetch(url: string, init: RequestInit): Promise { }).catch((error) => { span.addEvent('jira.proxySetupFailed', { errorName: error instanceof Error ? error.name : typeof error, - errorMessage: error instanceof Error ? error.message : String(error) + errorMessage: boundedIntegrationErrorMessage(error) }) }) try { @@ -372,10 +484,7 @@ async function jiraFetch(url: string, init: RequestInit): Promise { 'jira.transportErrorName', error instanceof Error ? error.name : typeof error ) - span.setAttribute( - 'jira.transportErrorMessage', - error instanceof Error ? error.message : String(error) - ) + span.setAttribute('jira.transportErrorMessage', boundedIntegrationErrorMessage(error)) const cause = describeErrorCause(error) if (cause) { span.setAttribute('jira.transportErrorCause', cause) @@ -410,16 +519,16 @@ async function requestWithCredentials( if (response.status === 204) { return null } - return response.json() + return readFetchResponseJsonWithinLimit(response) } async function readJiraError(response: Response): Promise { try { - const data = (await response.json()) as { + const data = await readFetchResponseJsonWithinLimit<{ errorMessages?: string[] errors?: Record message?: string - } + }>(response) const messages = [ ...(Array.isArray(data.errorMessages) ? data.errorMessages : []), ...Object.values(data.errors ?? {}), @@ -454,7 +563,7 @@ export async function jiraRequest( if (response.status === 204) { return null as T } - return (await response.json()) as T + return await readFetchResponseJsonWithinLimit(response) } export function getClients(selection?: JiraSiteSelection | null): JiraClientForSite[] { @@ -475,7 +584,11 @@ export function getClients(selection?: JiraSiteSelection | null): JiraClientForS // credentialError for getStatus to surface, so skip this site like a // missing token. A specific-site selection still rethrows so the renderer // can surface the decrypt banner promptly. - if (isAllSelection && error instanceof CredentialDecryptionError) { + if ( + isAllSelection && + (error instanceof CredentialDecryptionError || + error instanceof IntegrationAccountPersistenceLimitError) + ) { return [] } throw error @@ -504,9 +617,25 @@ export function getStatus(): JiraConnectionStatus { export async function connect( args: JiraConnectArgs ): Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> { + try { + assertIntegrationStringBytes( + 'Jira', + 'site URL', + args.siteUrl, + MAX_INTEGRATION_ACCOUNT_URL_BYTES + ) + assertIntegrationStringBytes('Jira', 'email', args.email, MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES) + assertIntegrationCredentialBytes('Jira', args.apiToken) + } catch (error) { + return { + ok: false, + error: error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Connection failed.' + } + } let siteUrl: string try { siteUrl = normalizeJiraSiteUrl(args.siteUrl) + assertIntegrationStringBytes('Jira', 'site URL', siteUrl, MAX_INTEGRATION_ACCOUNT_URL_BYTES) } catch { return { ok: false, error: 'Enter a valid Jira site URL.' } } @@ -527,6 +656,10 @@ export async function connect( return { ok: false, error: 'Email and API token are required.' } } + getSiteFile() + if (siteFileReadError) { + return { ok: false, error: siteFileReadError.message } + } await acquire() try { const myselfPath = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself' @@ -554,17 +687,23 @@ export async function connect( accountId: viewer.accountId, authType } - saveToken(id, apiToken) const file = getSiteFile() - writeSiteFile({ + const nextFile: JiraSiteFile = { version: 1, activeSiteId: id, selectedSiteId: id, sites: [site, ...file.sites.filter((entry) => entry.id !== id)] - }) + } + assertSiteFileBounds(nextFile) + serializeIntegrationAccountFile(nextFile) + saveToken(id, apiToken) + writeSiteFile(nextFile) return { ok: true, viewer } } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } + return { + ok: false, + error: error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Connection failed.' + } } finally { release() } @@ -572,6 +711,9 @@ export async function connect( export function disconnect(siteId?: string): void { const file = getSiteFile() + if (siteFileReadError) { + throw siteFileReadError + } const ids = siteId ? [siteId] : file.sites.map((site) => site.id) for (const id of ids) { deleteToken(id) @@ -604,7 +746,10 @@ export async function testConnection( try { client = getClients(siteId)[0] } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } + return { + ok: false, + error: error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Connection failed.' + } } if (!client) { return { ok: false, error: 'Not connected to Jira.' } @@ -617,15 +762,21 @@ export async function testConnection( ) return { ok: true, viewer } } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } + return { + ok: false, + error: error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Connection failed.' + } } finally { release() } } export function clearToken(siteId: string): void { - deleteToken(siteId) const file = getSiteFile() + if (siteFileReadError) { + throw siteFileReadError + } + deleteToken(siteId) writeSiteFile({ ...file, sites: file.sites.filter((site) => site.id !== siteId) }) } diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 2ce24a87420..9fcc774889f 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -21,6 +21,16 @@ import type { JiraTransition, JiraUser } from '../../shared/types' +import { + boundedIntegrationErrorLog, + boundedIntegrationErrorMessage +} from '../integration-error-message' +import { + INTEGRATION_PAGINATION_MAX_ITEMS, + IntegrationPaginationBudget, + INTEGRATION_PAGINATION_MAX_PAGES +} from '../integration-pagination-budget' +import { runBoundedIntegrationFanout } from '../integration-fanout' import { acquire, apiBasePath, @@ -83,16 +93,13 @@ function getErrorStatus(error: unknown): number | null { return typeof status === 'number' && Number.isFinite(status) ? status : null } -function toIssueSearchFailureError(error: unknown): unknown { +function toIssueSearchFailureError(error: unknown): Error { const status = getErrorStatus(error) - if ( - status === null || - !(error instanceof Error) || - error.message.startsWith(`Error ${status}:`) - ) { - return error + const message = boundedIntegrationErrorMessage(error) + if (status === null || message.startsWith(`Error ${status}:`)) { + return new Error(message) } - return new Error(`Error ${status}: ${error.message}`) + return new Error(boundedIntegrationErrorMessage(`Error ${status}: ${message}`)) } function shouldSurfaceSiteFailure( @@ -164,17 +171,28 @@ async function fetchPagedRecords( maxResults = 100 ): Promise { const records: JiraRecord[] = [] + const budget = new IntegrationPaginationBudget() let startAt = 0 - for (let guard = 0; guard < 100; guard += 1) { + for (let guard = 0; guard < INTEGRATION_PAGINATION_MAX_PAGES; guard += 1) { const response = await jiraRequest>( entry, pathForPage(startAt, maxResults) ) const items = getPageItems(response, key) - records.push(...items) + if (!budget.admitPage(items)) { + console.warn('[jira] Paginated result exceeded its retained result budget; truncating.') + break + } + for (const item of items) { + records.push(item) + } if (!shouldFetchNextPage(response, startAt, items, maxResults)) { break } + if (!budget.canRequestPage) { + console.warn('[jira] Paginated result reached its retained result budget; truncating.') + break + } startAt += asFiniteNumber(response.maxResults) ?? maxResults } return records @@ -374,7 +392,7 @@ async function searchIssuesForClient( fields: ISSUE_FIELDS }) }) - return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)) + return (result.issues ?? []).slice(0, limit).map((issue) => mapJiraIssue(entry.site, issue)) } export async function listIssues( @@ -397,8 +415,9 @@ export async function searchIssues( const safeLimit = clampLimit(limit) const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) - const results = await Promise.all( - entries.map(async (entry, index) => { + const fanout = await runBoundedIntegrationFanout( + entries, + async (entry, index) => { await acquire() try { return await searchIssuesForClient(entry, jql.trim(), safeLimit) @@ -410,13 +429,14 @@ export async function searchIssues( if (surfaceSiteFailure) { throw toIssueSearchFailureError(error) } - console.warn('[jira] searchIssues failed:', error) + console.warn('[jira] searchIssues failed:', boundedIntegrationErrorLog(error)) failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } return [] as JiraIssue[] } finally { release() } - }) + }, + (issues) => issues ) // 'all' fan-out: only surface an error when every connected site failed, so a // partial success (or a genuinely empty result) is not reported as an error. @@ -426,9 +446,11 @@ export async function searchIssues( if (recordedFailures.length === entries.length) { throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error } - return entries.length === 1 - ? results.flat().slice(0, safeLimit) - : sortAndLimitIssues(results.flat(), safeLimit) + if (fanout.truncated) { + console.warn('[jira] Cross-site search exceeded its aggregate result budget; truncating.') + } + const results = fanout.results.flat() + return entries.length === 1 ? results.slice(0, safeLimit) : sortAndLimitIssues(results, safeLimit) } export async function getIssue( @@ -453,7 +475,7 @@ export async function getIssue( throw error } } else { - console.warn('[jira] getIssue failed:', error) + console.warn('[jira] getIssue failed:', boundedIntegrationErrorLog(error)) } } finally { release() @@ -631,7 +653,7 @@ export async function getIssueComments( clearToken(entry.site.id) throw error } - console.warn('[jira] getIssueComments failed:', error) + console.warn('[jira] getIssueComments failed:', boundedIntegrationErrorLog(error)) return [] } finally { release() @@ -643,8 +665,9 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise { + const fanout = await runBoundedIntegrationFanout( + entries, + async (entry) => { await acquire() try { // Server/DC has no /project/search resource; /project returns the @@ -659,7 +682,11 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise mapProject(project, entry.site)) + const retainedProjects = projects.slice(0, INTEGRATION_PAGINATION_MAX_ITEMS) + if (projects.length > retainedProjects.length) { + console.warn('[jira] Projects returned more rows than supported; truncating.') + } + return retainedProjects.map((project) => mapProject(project, entry.site)) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) @@ -667,15 +694,19 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise projects ) - return results.flat().sort((a, b) => a.name.localeCompare(b.name)) + if (fanout.truncated) { + console.warn('[jira] Cross-site projects exceeded their aggregate result budget; truncating.') + } + return fanout.results.flat().sort((a, b) => a.name.localeCompare(b.name)) } export async function listIssueTypes( @@ -704,7 +735,7 @@ export async function listIssueTypes( clearToken(entry.site.id) throw error } - console.warn('[jira] listIssueTypes failed:', error) + console.warn('[jira] listIssueTypes failed:', boundedIntegrationErrorLog(error)) return [] } finally { release() @@ -723,9 +754,10 @@ export async function listCreateFields( await acquire() try { const fields: JiraCreateField[] = [] + const budget = new IntegrationPaginationBudget() let startAt = 0 const maxResults = 100 - for (let guard = 0; guard < 100; guard += 1) { + for (let guard = 0; guard < INTEGRATION_PAGINATION_MAX_PAGES; guard += 1) { const params = new URLSearchParams({ maxResults: String(maxResults), startAt: String(startAt) @@ -737,14 +769,23 @@ export async function listCreateFields( )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` ) const records = getCreateFieldRecords(response) - fields.push( - ...records - .map((record) => mapCreateField(record)) - .filter((field): field is JiraCreateField => field !== null) - ) + if (!budget.admitPage(records)) { + console.warn('[jira] Create fields exceeded their retained result budget; truncating.') + break + } + for (const record of records) { + const field = mapCreateField(record) + if (field) { + fields.push(field) + } + } if (!shouldFetchNextPage(response, startAt, records, maxResults)) { break } + if (!budget.canRequestPage) { + console.warn('[jira] Create fields reached their retained result budget; truncating.') + break + } startAt += asFiniteNumber(response.maxResults) ?? maxResults } return fields @@ -753,7 +794,7 @@ export async function listCreateFields( clearToken(entry.site.id) throw error } - console.warn('[jira] listCreateFields failed:', error) + console.warn('[jira] listCreateFields failed:', boundedIntegrationErrorLog(error)) return [] } finally { release() @@ -768,13 +809,16 @@ export async function listPriorities(siteId?: string | null): Promise(entry, `${apiBasePath(entry.site)}/priority`) - return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) + return response + .slice(0, INTEGRATION_PAGINATION_MAX_ITEMS) + .map(mapPriority) + .filter((priority): priority is JiraPriority => !!priority) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) throw error } - console.warn('[jira] listPriorities failed:', error) + console.warn('[jira] listPriorities failed:', boundedIntegrationErrorLog(error)) return [] } finally { release() @@ -802,13 +846,16 @@ export async function listAssignableUsers( entry, `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` ) - return response.map(mapUser).filter((user): user is JiraUser => !!user) + return response + .slice(0, 50) + .map(mapUser) + .filter((user): user is JiraUser => !!user) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) throw error } - console.warn('[jira] listAssignableUsers failed:', error) + console.warn('[jira] listAssignableUsers failed:', boundedIntegrationErrorLog(error)) return [] } finally { release() @@ -829,17 +876,19 @@ export async function listTransitions( entry, `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` ) - return (response.transitions ?? []).map((transition) => ({ - id: asString(transition.id), - name: asString(transition.name), - to: mapStatus(transition.to) - })) + return (response.transitions ?? []) + .slice(0, INTEGRATION_PAGINATION_MAX_ITEMS) + .map((transition) => ({ + id: asString(transition.id), + name: asString(transition.name), + to: mapStatus(transition.to) + })) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) throw error } - console.warn('[jira] listTransitions failed:', error) + console.warn('[jira] listTransitions failed:', boundedIntegrationErrorLog(error)) return [] } finally { release() @@ -891,12 +940,18 @@ export async function getProjectStatusOrder( const seenStatusIds = new Set() const statusIdsByColumn: string[][] = [] for (const column of columns) { + if (seenStatusIds.size >= INTEGRATION_PAGINATION_MAX_ITEMS) { + break + } const statuses = asRecord(column).statuses if (!Array.isArray(statuses)) { continue } const columnStatusIds: string[] = [] for (const status of statuses) { + if (seenStatusIds.size >= INTEGRATION_PAGINATION_MAX_ITEMS) { + break + } const statusId = asIdentifier(asRecord(status).id) if (statusId && !seenStatusIds.has(statusId)) { seenStatusIds.add(statusId) @@ -913,7 +968,7 @@ export async function getProjectStatusOrder( clearToken(entry.site.id) throw error } - console.warn('[jira] getProjectStatusOrder failed:', error) + console.warn('[jira] getProjectStatusOrder failed:', boundedIntegrationErrorLog(error)) return { statusIdsByColumn: [] } } finally { release() diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 94f5d11ea3e..87777a15670 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -1,17 +1,14 @@ -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - unlinkSync, - writeFileSync -} from 'node:fs' +import { copyFileSync, existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join, posix as pathPosix } from 'node:path' import { randomUUID } from 'node:crypto' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + NodeFileReadTooLargeError, + readNodeFileSyncWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' +import { AGENT_HOOK_CONFIG_MAX_BYTES } from '../agent-hooks/agent-hook-file-limits' import { createManagedCommandMatcher, getSharedManagedScriptPath, @@ -99,7 +96,9 @@ function readConfigToml(configPath: string): string | null { return '' } try { - return readFileSync(configPath, 'utf-8') + return readNodeFileSyncWithinLimit(configPath, AGENT_HOOK_CONFIG_MAX_BYTES).buffer.toString( + 'utf8' + ) } catch { return null } @@ -112,10 +111,17 @@ function writeConfigToml(configPath: string, text: string): void { mkdirSync(dir, { recursive: true }) if (existsSync(configPath)) { try { - if (readFileSync(configPath, 'utf-8') === text) { + const existing = readNodeFileSyncWithinLimit( + configPath, + AGENT_HOOK_CONFIG_MAX_BYTES + ).buffer.toString('utf8') + if (existing === text) { return } - } catch { + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw error + } // Fall through to the atomic write path. } } diff --git a/src/main/lib/fetch-response-body.test.ts b/src/main/lib/fetch-response-body.test.ts new file mode 100644 index 00000000000..197c2196ee8 --- /dev/null +++ b/src/main/lib/fetch-response-body.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { + FetchResponseBodyTooLargeError, + readFetchResponseBytesWithinLimit, + readFetchResponseJsonWithinLimit, + readFetchResponseTextWithinLimit +} from './fetch-response-body' + +function chunkedResponse(chunks: Uint8Array[], onCancel?: () => void): Response { + return new Response( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk) + } + controller.close() + }, + cancel() { + onCancel?.() + } + }) + ) +} + +describe('bounded fetch response bodies', () => { + it('preserves bytes, split UTF-8, and parsed JSON below the limit', async () => { + const encoded = new TextEncoder().encode('{"message":"hello 🐋"}') + const chunks = [encoded.subarray(0, 20), encoded.subarray(20, 22), encoded.subarray(22)] + + await expect(readFetchResponseBytesWithinLimit(chunkedResponse(chunks), 1024)).resolves.toEqual( + encoded + ) + await expect(readFetchResponseTextWithinLimit(chunkedResponse(chunks), 1024)).resolves.toBe( + '{"message":"hello 🐋"}' + ) + await expect( + readFetchResponseJsonWithinLimit<{ message: string }>(chunkedResponse(chunks), 1024) + ).resolves.toEqual({ message: 'hello 🐋' }) + }) + + it('rejects an oversized declared body before reading it', async () => { + let cancelled = false + const response = chunkedResponse([new Uint8Array([1])], () => { + cancelled = true + }) + response.headers.set('content-length', '1025') + + await expect(readFetchResponseBytesWithinLimit(response, 1024)).rejects.toThrow( + FetchResponseBodyTooLargeError + ) + expect(cancelled).toBe(true) + }) + + it('cancels a chunked body as soon as accumulated bytes exceed the limit', async () => { + let cancelled = false + const chunks = [new Uint8Array(700), new Uint8Array(400)] + let index = 0 + const response = new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(chunks[index++] ?? new Uint8Array()) + }, + cancel() { + cancelled = true + } + }) + ) + + await expect(readFetchResponseBytesWithinLimit(response, 1024)).rejects.toThrow( + FetchResponseBodyTooLargeError + ) + expect(cancelled).toBe(true) + }) + + it('rejects invalid limits and preserves native JSON parse failures', async () => { + await expect(readFetchResponseBytesWithinLimit(new Response('ok'), -1)).rejects.toThrow( + RangeError + ) + await expect(readFetchResponseJsonWithinLimit(new Response(''), 10)).rejects.toThrow( + SyntaxError + ) + }) + + it('rejects structural amplification before JSON.parse', async () => { + const parseSpy = vi.spyOn(JSON, 'parse') + + await expect( + readFetchResponseJsonWithinLimit(new Response('[0,0]'), 1024, { + structuralTokens: 2, + nestingDepth: 4 + }) + ).rejects.toThrow('JSON structure exceeds 2 tokens') + expect(parseSpy).not.toHaveBeenCalled() + }) + + it('preserves injected adapters that expose only high-level response methods', async () => { + const textResponse = { text: async () => 'adapter text' } as Response + const jsonResponse = { json: async () => ({ source: 'adapter' }) } as Response + + await expect(readFetchResponseTextWithinLimit(textResponse)).resolves.toBe('adapter text') + await expect(readFetchResponseJsonWithinLimit(jsonResponse)).resolves.toEqual({ + source: 'adapter' + }) + }) +}) diff --git a/src/main/lib/fetch-response-body.ts b/src/main/lib/fetch-response-body.ts new file mode 100644 index 00000000000..b59b540fc35 --- /dev/null +++ b/src/main/lib/fetch-response-body.ts @@ -0,0 +1 @@ +export * from '../../shared/fetch-response-body' diff --git a/src/main/lib/html-to-pdf-memory-limit.test.ts b/src/main/lib/html-to-pdf-memory-limit.test.ts new file mode 100644 index 00000000000..fc9e92e3804 --- /dev/null +++ b/src/main/lib/html-to-pdf-memory-limit.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { + assertHtmlToPdfInputWithinMemoryLimit, + HTML_TO_PDF_MEMORY_LIMIT_ERROR +} from './html-to-pdf-memory-limit' + +describe('HTML to PDF memory limit', () => { + it('measures UTF-8 bytes and accepts the boundary', () => { + expect(() => assertHtmlToPdfInputWithinMemoryLimit('éé', 4)).not.toThrow() + }) + + it('rejects the next byte', () => { + expect(() => assertHtmlToPdfInputWithinMemoryLimit('ééa', 4)).toThrow( + HTML_TO_PDF_MEMORY_LIMIT_ERROR + ) + }) +}) diff --git a/src/main/lib/html-to-pdf-memory-limit.ts b/src/main/lib/html-to-pdf-memory-limit.ts new file mode 100644 index 00000000000..6b04e8b59e4 --- /dev/null +++ b/src/main/lib/html-to-pdf-memory-limit.ts @@ -0,0 +1 @@ +export * from '../../shared/html-to-pdf-memory-limit' diff --git a/src/main/lib/html-to-pdf.test.ts b/src/main/lib/html-to-pdf.test.ts new file mode 100644 index 00000000000..9a0f197b69e --- /dev/null +++ b/src/main/lib/html-to-pdf.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + BrowserWindowMock, + destroyMock, + executeJavaScriptMock, + loadFileMock, + printToPDFMock, + unlinkMock, + writeFileMock, + setDidFinishLoad +} = vi.hoisted(() => { + let didFinishLoad: (() => void) | undefined + const setDidFinishLoad = (listener: (() => void) | undefined): void => { + didFinishLoad = listener + } + const webContentsOnceMock = vi.fn( + (event: string, listener: (...args: unknown[]) => void): void => { + if (event === 'did-finish-load') { + didFinishLoad = () => listener() + } + } + ) + const executeJavaScriptMock = vi.fn(async () => undefined) + const printToPDFMock = vi.fn(async () => Buffer.from('%PDF-export')) + const loadFileMock = vi.fn(async () => { + didFinishLoad?.() + }) + const destroyMock = vi.fn() + const exportWindow = { + webContents: { + once: webContentsOnceMock, + executeJavaScript: executeJavaScriptMock, + printToPDF: printToPDFMock + }, + loadFile: loadFileMock, + isDestroyed: vi.fn(() => false), + destroy: destroyMock + } + return { + BrowserWindowMock: vi.fn(function () { + return exportWindow + }), + destroyMock, + executeJavaScriptMock, + loadFileMock, + printToPDFMock, + unlinkMock: vi.fn(async () => undefined), + writeFileMock: vi.fn(async () => undefined), + setDidFinishLoad + } +}) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp') }, + BrowserWindow: BrowserWindowMock +})) +vi.mock('node:fs/promises', () => ({ writeFile: writeFileMock, unlink: unlinkMock })) +vi.mock('node:crypto', () => ({ randomUUID: vi.fn(() => 'test-id') })) + +import { htmlToPdf } from './html-to-pdf' + +describe('htmlToPdf', () => { + beforeEach(() => { + vi.clearAllMocks() + setDidFinishLoad(undefined) + printToPDFMock.mockResolvedValue(Buffer.from('%PDF-export')) + }) + + it('preserves ordinary export rendering and cleanup under the memory admission', async () => { + await expect(htmlToPdf('Hello')).resolves.toEqual( + Buffer.from('%PDF-export') + ) + + expect(writeFileMock).toHaveBeenCalledWith( + '/tmp/orca-export-test-id.html', + 'Hello', + 'utf-8' + ) + expect(loadFileMock).toHaveBeenCalledWith('/tmp/orca-export-test-id.html') + expect(executeJavaScriptMock).toHaveBeenCalledOnce() + expect(printToPDFMock).toHaveBeenCalledWith({ + printBackground: true, + pageSize: 'A4', + margins: { top: 0.75, bottom: 0.75, left: 0.75, right: 0.75 } + }) + expect(destroyMock).toHaveBeenCalledOnce() + expect(unlinkMock).toHaveBeenCalledWith('/tmp/orca-export-test-id.html') + }) +}) diff --git a/src/main/lib/html-to-pdf.ts b/src/main/lib/html-to-pdf.ts index f0cc467e515..a50578fcee5 100644 --- a/src/main/lib/html-to-pdf.ts +++ b/src/main/lib/html-to-pdf.ts @@ -2,6 +2,12 @@ import { app, BrowserWindow } from 'electron' import { writeFile, unlink } from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' +import { + acquireBrowserPdfAdmission, + BROWSER_PDF_BUSY_ERROR +} from '../browser/browser-pdf-admission' +import { assertCdpPdfWithinMemoryLimit } from '../browser/cdp-print-to-pdf' +import { assertHtmlToPdfInputWithinMemoryLimit } from './html-to-pdf-memory-limit' export class ExportTimeoutError extends Error { constructor(message = 'Export timed out') { @@ -31,40 +37,42 @@ new Promise((resolve) => { ` export async function htmlToPdf(html: string): Promise { + assertHtmlToPdfInputWithinMemoryLimit(html) const tempDir = app.getPath('temp') const tempPath = path.join(tempDir, `orca-export-${randomUUID()}.html`) - await writeFile(tempPath, html, 'utf-8') - - const win = new BrowserWindow({ - show: false, - webPreferences: { - sandbox: true, - contextIsolation: true, - nodeIntegration: false, - // Why: image-wait needs to run a short script inside the export page, and - // the exported renderer DOM may already embed scripts/SVGs (e.g. Mermaid) - // that need JS to paint correctly. The window stays sandboxed and - // isolated so this is safe. - javascript: true - } - }) - + const pdfAdmission = acquireBrowserPdfAdmission() + if (!pdfAdmission) { + throw new Error(BROWSER_PDF_BUSY_ERROR) + } + let win: BrowserWindow | null = null let timer: NodeJS.Timeout | undefined try { + await writeFile(tempPath, html, 'utf-8') + win = new BrowserWindow({ + show: false, + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + // Why: image-wait needs script execution to finish remote images and rendered SVGs. + javascript: true + } + }) + const exportWindow = win const loadPromise = new Promise((resolve, reject) => { - win.webContents.once('did-finish-load', () => resolve()) - win.webContents.once('did-fail-load', (_event, errorCode, errorDescription) => { + exportWindow.webContents.once('did-finish-load', () => resolve()) + exportWindow.webContents.once('did-fail-load', (_event, errorCode, errorDescription) => { reject(new Error(`Failed to load export document: ${errorDescription} (${errorCode})`)) }) }) - await win.loadFile(tempPath) + await exportWindow.loadFile(tempPath) await loadPromise const renderAndPrint = (async (): Promise => { - await win.webContents.executeJavaScript(WAIT_FOR_IMAGES_SCRIPT, true) - return win.webContents.printToPDF({ + await exportWindow.webContents.executeJavaScript(WAIT_FOR_IMAGES_SCRIPT, true) + return pdfAdmission.print(exportWindow.webContents, { printBackground: true, pageSize: 'A4', margins: { @@ -80,12 +88,15 @@ export async function htmlToPdf(html: string): Promise { timer = setTimeout(() => reject(new ExportTimeoutError()), EXPORT_TIMEOUT_MS) }) - return await Promise.race([renderAndPrint, timeoutPromise]) + const pdf = await Promise.race([renderAndPrint, timeoutPromise]) + assertCdpPdfWithinMemoryLimit(pdf) + return pdf } finally { + pdfAdmission.releaseIfIdle() if (timer) { clearTimeout(timer) } - if (!win.isDestroyed()) { + if (win && !win.isDestroyed()) { win.destroy() } try { diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index 7f518afe6e8..82fb079bdd6 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -3,6 +3,10 @@ import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + MAX_INTEGRATION_ACCOUNTS, + MAX_INTEGRATION_CREDENTIAL_BYTES +} from '../integration-account-persistence-limits' type ViewerFixture = { displayName: string @@ -389,4 +393,57 @@ describe('Linear client workspace storage', () => { expect(() => linear.getClients('bad')).toThrow('Could not decrypt') }) + + it('admits the exact saved-workspace boundary without changing order', async () => { + const workspaces = Array.from({ length: MAX_INTEGRATION_ACCOUNTS }, (_, index) => ({ + id: `workspace-${index}`, + token: `token-${index}` + })) + writeMultiWorkspaceFiles(workspaces, 'all') + const linear = await loadClientModule() + + const status = linear.getStatus() + expect(status.workspaces).toHaveLength(MAX_INTEGRATION_ACCOUNTS) + expect(status.workspaces?.map((workspace) => workspace.id)).toEqual( + workspaces.map((workspace) => workspace.id) + ) + }) + + it('preserves an over-limit saved-workspace file and refuses to overwrite it', async () => { + const workspaces = Array.from({ length: MAX_INTEGRATION_ACCOUNTS + 1 }, (_, index) => ({ + id: `workspace-${index}`, + token: `token-${index}` + })) + writeMultiWorkspaceFiles(workspaces, 'all') + const path = join(tempHome, '.orca', 'linear-workspaces.json') + const before = readFileSync(path, 'utf8') + const linear = await loadClientModule() + + expect(linear.getStatus()).toMatchObject({ connected: false, workspaces: [] }) + await expect(linear.connect('token-alpha')).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('left unchanged') + }) + expect(linearClientMock).not.toHaveBeenCalled() + expect(readFileSync(path, 'utf8')).toBe(before) + }) + + it('admits an exact-size Linear credential and rejects credential byte +1 before SDK use', async () => { + const exactToken = 't'.repeat(MAX_INTEGRATION_CREDENTIAL_BYTES) + fixtures.set(exactToken, { + displayName: 'Ada', + email: 'ada@example.com', + organizationId: 'org-exact', + organizationName: 'Exact', + organizationUrlKey: 'exact' + }) + const linear = await loadClientModule() + + await expect(linear.connect(exactToken)).resolves.toMatchObject({ ok: true }) + await expect(linear.connect(`${exactToken}t`)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining(`${MAX_INTEGRATION_CREDENTIAL_BYTES} UTF-8 bytes`) + }) + expect(linearClientMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index 7058d130597..ca71dbb3d0d 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -3,13 +3,15 @@ stay in one consistency boundary. */ import { safeStorage } from 'electron' import type { LinearClient } from '@linear/sdk' -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { loadLinearSdk } from './linear-sdk' import { CredentialDecryptionError, credentialFileHasContent, + readIntegrationCredentialFileSync, + readIntegrationCredentialFileSyncText, readStoredCredentialToken } from '../integration-credential-file' import type { @@ -18,31 +20,32 @@ import type { LinearWorkspace, LinearWorkspaceSelection } from '../../shared/types' +import { IntegrationApiConcurrencyGate } from '../integration-api-concurrency' +import { + assertIntegrationAccountCount, + assertIntegrationCredentialBytes, + assertIntegrationStringBytes, + IntegrationAccountPersistenceLimitError, + MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES, + MAX_INTEGRATION_ACCOUNT_FILE_BYTES, + MAX_INTEGRATION_ACCOUNT_ID_BYTES, + MAX_INTEGRATION_ACCOUNT_LABEL_BYTES, + MAX_INTEGRATION_ACCOUNTS, + serializeIntegrationAccountFile, + unreadableIntegrationAccountFileError +} from '../integration-account-persistence-limits' +import { boundedIntegrationErrorMessage } from '../integration-error-message' // ── Concurrency limiter — max 4 parallel Linear API calls ──────────── const MAX_CONCURRENT = 4 -let running = 0 -const queue: (() => void)[] = [] +const concurrencyGate = new IntegrationApiConcurrencyGate(MAX_CONCURRENT) export function acquire(): Promise { - if (running < MAX_CONCURRENT) { - running++ - return Promise.resolve() - } - return new Promise((resolve) => - queue.push(() => { - running++ - resolve() - }) - ) + return concurrencyGate.acquire() } export function release(): void { - running-- - const next = queue.shift() - if (next) { - next() - } + concurrencyGate.release() } // ── Token + workspace storage ──────────────────────────────────────── @@ -74,6 +77,17 @@ let cachedLegacyViewer: LinearViewer | null = null let legacyViewerLoadedFromDisk = false let cachedWorkspaceFile: LinearWorkspaceFile | null = null let workspaceFileLoadedFromDisk = false +let workspaceFileReadError: Error | null = null + +function cacheToken(workspaceId: string, token: string): void { + if (!cachedTokens.has(workspaceId) && cachedTokens.size >= MAX_INTEGRATION_ACCOUNTS) { + const oldestWorkspaceId = cachedTokens.keys().next().value + if (oldestWorkspaceId !== undefined) { + cachedTokens.delete(oldestWorkspaceId) + } + } + cachedTokens.set(workspaceId, token) +} function getOrcaDir(): string { return join(homedir(), '.orca') @@ -99,6 +113,12 @@ function getWorkspaceTokenPath(workspaceId: string): string { if (workspaceId === LEGACY_WORKSPACE_ID) { return getLegacyTokenPath() } + assertIntegrationStringBytes( + 'Linear', + 'workspace ID', + workspaceId, + MAX_INTEGRATION_ACCOUNT_ID_BYTES + ) return join(getWorkspaceTokenDir(), `${Buffer.from(workspaceId).toString('base64url')}.enc`) } @@ -122,11 +142,47 @@ function readLegacyViewerFromDisk(): LinearViewer | null { return null } try { - const raw = readFileSync(path, { encoding: 'utf-8' }) + const raw = readIntegrationCredentialFileSyncText(path) const parsed = JSON.parse(raw) as Partial if (typeof parsed?.displayName !== 'string' || typeof parsed?.organizationName !== 'string') { return null } + assertIntegrationStringBytes( + 'Linear', + 'legacy display name', + parsed.displayName, + MAX_INTEGRATION_ACCOUNT_LABEL_BYTES + ) + assertIntegrationStringBytes( + 'Linear', + 'legacy organization name', + parsed.organizationName, + MAX_INTEGRATION_ACCOUNT_LABEL_BYTES + ) + if (typeof parsed.email === 'string') { + assertIntegrationStringBytes( + 'Linear', + 'legacy email', + parsed.email, + MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES + ) + } + if (typeof parsed.organizationId === 'string') { + assertIntegrationStringBytes( + 'Linear', + 'legacy organization ID', + parsed.organizationId, + MAX_INTEGRATION_ACCOUNT_ID_BYTES + ) + } + if (typeof parsed.organizationUrlKey === 'string') { + assertIntegrationStringBytes( + 'Linear', + 'legacy organization URL key', + parsed.organizationUrlKey, + MAX_INTEGRATION_ACCOUNT_LABEL_BYTES + ) + } return { displayName: parsed.displayName, email: typeof parsed.email === 'string' ? parsed.email : null, @@ -180,6 +236,76 @@ function normalizeWorkspace(input: unknown): LinearWorkspace | null { } } +function assertWorkspaceBounds(workspace: LinearWorkspace): void { + assertIntegrationStringBytes( + 'Linear', + 'workspace ID', + workspace.id, + MAX_INTEGRATION_ACCOUNT_ID_BYTES + ) + assertIntegrationStringBytes( + 'Linear', + 'organization ID', + workspace.organizationId, + MAX_INTEGRATION_ACCOUNT_ID_BYTES + ) + assertIntegrationStringBytes( + 'Linear', + 'organization name', + workspace.organizationName, + MAX_INTEGRATION_ACCOUNT_LABEL_BYTES + ) + if (workspace.organizationUrlKey !== undefined) { + assertIntegrationStringBytes( + 'Linear', + 'organization URL key', + workspace.organizationUrlKey, + MAX_INTEGRATION_ACCOUNT_LABEL_BYTES + ) + } + assertIntegrationStringBytes( + 'Linear', + 'display name', + workspace.displayName, + MAX_INTEGRATION_ACCOUNT_LABEL_BYTES + ) + if (workspace.email !== null) { + assertIntegrationStringBytes( + 'Linear', + 'email', + workspace.email, + MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES + ) + } +} + +function assertStoredWorkspaceBounds(input: unknown): void { + if (!input || typeof input !== 'object') { + return + } + const record = input as Record + const fields = [ + ['workspace ID', record.id, MAX_INTEGRATION_ACCOUNT_ID_BYTES], + ['organization ID', record.organizationId, MAX_INTEGRATION_ACCOUNT_ID_BYTES], + ['organization name', record.organizationName, MAX_INTEGRATION_ACCOUNT_LABEL_BYTES], + ['organization URL key', record.organizationUrlKey, MAX_INTEGRATION_ACCOUNT_LABEL_BYTES], + ['display name', record.displayName, MAX_INTEGRATION_ACCOUNT_LABEL_BYTES], + ['email', record.email, MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES] + ] as const + for (const [field, value, maxBytes] of fields) { + if (typeof value === 'string') { + assertIntegrationStringBytes('Linear', field, value, maxBytes) + } + } +} + +function assertWorkspaceFileBounds(file: LinearWorkspaceFile): void { + assertIntegrationAccountCount('Linear', file.workspaces.length) + for (const workspace of file.workspaces) { + assertWorkspaceBounds(workspace) + } +} + function emptyWorkspaceFile(): LinearWorkspaceFile { return { version: 1, @@ -192,17 +318,49 @@ function emptyWorkspaceFile(): LinearWorkspaceFile { function readWorkspaceFileFromDisk(): LinearWorkspaceFile { const path = getWorkspaceFilePath() if (!existsSync(path)) { + workspaceFileReadError = null return emptyWorkspaceFile() } try { - const raw = readFileSync(path, { encoding: 'utf-8' }) + const raw = readIntegrationCredentialFileSyncText(path) const parsed = JSON.parse(raw) as Partial - const workspaces = Array.isArray(parsed.workspaces) - ? parsed.workspaces - .map((workspace) => normalizeWorkspace(workspace)) - .filter((workspace): workspace is LinearWorkspace => workspace !== null) - .filter((workspace) => hasStoredToken(workspace.id)) - : [] + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + parsed.version !== 1 || + !Array.isArray(parsed.workspaces) + ) { + throw unreadableIntegrationAccountFileError('Linear') + } + if (typeof parsed.activeWorkspaceId === 'string') { + assertIntegrationStringBytes( + 'Linear', + 'active workspace ID', + parsed.activeWorkspaceId, + MAX_INTEGRATION_ACCOUNT_ID_BYTES + ) + } + if (typeof parsed.selectedWorkspaceId === 'string' && parsed.selectedWorkspaceId !== 'all') { + assertIntegrationStringBytes( + 'Linear', + 'selected workspace ID', + parsed.selectedWorkspaceId, + MAX_INTEGRATION_ACCOUNT_ID_BYTES + ) + } + const workspaces: LinearWorkspace[] = [] + assertIntegrationAccountCount('Linear', parsed.workspaces.length) + for (const input of parsed.workspaces) { + assertStoredWorkspaceBounds(input) + const workspace = normalizeWorkspace(input) + if (!workspace) { + throw unreadableIntegrationAccountFileError('Linear') + } + if (hasStoredToken(workspace.id)) { + workspaces.push(workspace) + } + } const activeWorkspaceId = typeof parsed.activeWorkspaceId === 'string' && workspaces.some((workspace) => workspace.id === parsed.activeWorkspaceId) @@ -215,6 +373,7 @@ function readWorkspaceFileFromDisk(): LinearWorkspaceFile { ? parsed.selectedWorkspaceId : activeWorkspaceId + workspaceFileReadError = null return { version: 1, activeWorkspaceId, @@ -222,6 +381,7 @@ function readWorkspaceFileFromDisk(): LinearWorkspaceFile { workspaces } } catch { + workspaceFileReadError = unreadableIntegrationAccountFileError('Linear') return emptyWorkspaceFile() } } @@ -235,6 +395,10 @@ function getWorkspaceFile(): LinearWorkspaceFile { } function writeWorkspaceFile(file: LinearWorkspaceFile): void { + if (workspaceFileReadError) { + throw workspaceFileReadError + } + assertWorkspaceFileBounds(file) ensureOrcaDir() const persistedWorkspaces = file.workspaces.filter( (workspace) => workspace.id !== LEGACY_WORKSPACE_ID @@ -255,17 +419,19 @@ function writeWorkspaceFile(file: LinearWorkspaceFile): void { ? file.selectedWorkspaceId : activeWorkspaceId - cachedWorkspaceFile = { + const nextFile: LinearWorkspaceFile = { version: 1, activeWorkspaceId, selectedWorkspaceId, workspaces: persistedWorkspaces } - workspaceFileLoadedFromDisk = true - writeFileSync(getWorkspaceFilePath(), JSON.stringify(cachedWorkspaceFile, null, 2), { + const serialized = serializeIntegrationAccountFile(nextFile) + writeFileSync(getWorkspaceFilePath(), serialized, { encoding: 'utf-8', mode: 0o600 }) + cachedWorkspaceFile = nextFile + workspaceFileLoadedFromDisk = true } function getLegacyWorkspace(): LinearWorkspace | null { @@ -321,8 +487,14 @@ function clearLegacyViewerOnDisk(): void { } function writeEncryptedToken(path: string, apiKey: string): void { + assertIntegrationCredentialBytes('Linear', apiKey) if (safeStorage.isEncryptionAvailable()) { const encrypted = safeStorage.encryptString(apiKey) + if (encrypted.length > MAX_INTEGRATION_ACCOUNT_FILE_BYTES) { + throw new IntegrationAccountPersistenceLimitError( + `Linear encrypted credential exceeds ${MAX_INTEGRATION_ACCOUNT_FILE_BYTES} bytes.` + ) + } writeFileSync(path, encrypted, { mode: 0o600 }) return } @@ -338,7 +510,7 @@ function saveWorkspaceToken(workspaceId: string, apiKey: string): void { } const tokenPath = getWorkspaceTokenPath(workspaceId) writeEncryptedToken(tokenPath, apiKey) - cachedTokens.set(workspaceId, apiKey) + cacheToken(workspaceId, apiKey) credentialErrors.delete(workspaceId) } @@ -352,6 +524,12 @@ export function loadToken(options: { force?: boolean; workspaceId?: string } = { if (!workspaceId) { return null } + if ( + workspaceId !== LEGACY_WORKSPACE_ID && + !getWorkspaceState().workspaces.some((workspace) => workspace.id === workspaceId) + ) { + return null + } const cached = cachedTokens.get(workspaceId) if (cached !== undefined) { return cached @@ -364,15 +542,19 @@ export function loadToken(options: { force?: boolean; workspaceId?: string } = { return null } try { - const raw = readFileSync(tokenPath) + const raw = readIntegrationCredentialFileSync(tokenPath) const token = readStoredCredentialToken('Linear', raw) if (token) { - cachedTokens.set(workspaceId, token) + assertIntegrationCredentialBytes('Linear', token) + cacheToken(workspaceId, token) } credentialErrors.delete(workspaceId) return token } catch (error) { - if (error instanceof CredentialDecryptionError) { + if ( + error instanceof CredentialDecryptionError || + error instanceof IntegrationAccountPersistenceLimitError + ) { credentialErrors.set(workspaceId, error.message) throw error } @@ -401,6 +583,10 @@ function clearTokenFile(workspaceId: string): void { } export function clearToken(workspaceId?: string): void { + getWorkspaceFile() + if (workspaceFileReadError) { + throw workspaceFileReadError + } if (!workspaceId) { const state = getWorkspaceState() for (const workspace of state.workspaces) { @@ -417,6 +603,9 @@ export function clearToken(workspaceId?: string): void { return } + if (!getWorkspaceState().workspaces.some((workspace) => workspace.id === workspaceId)) { + return + } clearTokenFile(workspaceId) if (workspaceId === LEGACY_WORKSPACE_ID) { cachedLegacyViewer = null @@ -453,7 +642,10 @@ function workspaceFromLinearData( } } -function upsertWorkspace(workspace: LinearWorkspace, options: { select?: boolean } = {}): void { +function workspaceFileWithUpsert( + workspace: LinearWorkspace, + options: { select?: boolean } = {} +): LinearWorkspaceFile { const file = getWorkspaceFile() const current = file.workspaces.find((entry) => entry.id === workspace.id) const credentialRevision = (current?.credentialRevision ?? 0) + 1 @@ -467,12 +659,16 @@ function upsertWorkspace(workspace: LinearWorkspace, options: { select?: boolean : file.selectedWorkspaceId && file.selectedWorkspaceId !== LEGACY_WORKSPACE_ID ? file.selectedWorkspaceId : workspace.id - writeWorkspaceFile({ + return { version: 1, activeWorkspaceId: workspace.id, selectedWorkspaceId, workspaces - }) + } +} + +function upsertWorkspace(workspace: LinearWorkspace, options: { select?: boolean } = {}): void { + writeWorkspaceFile(workspaceFileWithUpsert(workspace, options)) } function replaceLegacyWorkspace(workspace: LinearWorkspace, token: string): void { @@ -486,7 +682,8 @@ function replaceLegacyWorkspace(workspace: LinearWorkspace, token: string): void function resolveWorkspaceId(workspaceId?: string | null): string | null { if (workspaceId && workspaceId !== 'all') { - return workspaceId + const state = getWorkspaceState() + return state.workspaces.some((workspace) => workspace.id === workspaceId) ? workspaceId : null } const state = getWorkspaceState() if ( @@ -539,7 +736,11 @@ export function getClients( // per-workspace credentialError for getStatus to surface, so skip this // workspace like a missing token. A specific-workspace selection still // rethrows so the renderer can surface the decrypt banner promptly. - if (isAllSelection && error instanceof CredentialDecryptionError) { + if ( + isAllSelection && + (error instanceof CredentialDecryptionError || + error instanceof IntegrationAccountPersistenceLimitError) + ) { continue } throw error @@ -580,13 +781,22 @@ export async function connect( { ok: true; viewer: LinearViewer; workspace: LinearWorkspace } | { ok: false; error: string } > { try { + assertIntegrationCredentialBytes('Linear', apiKey) + getWorkspaceFile() + if (workspaceFileReadError) { + throw workspaceFileReadError + } const client = new (loadLinearSdk().LinearClient)({ apiKey }) const me = await client.viewer const org = await me.organization const workspace = workspaceFromLinearData(me, org) - saveWorkspaceToken(workspace.id, apiKey) + assertWorkspaceBounds(workspace) const legacyWorkspace = getLegacyWorkspace() + const candidateFile = workspaceFileWithUpsert(workspace, { select: true }) + assertWorkspaceFileBounds(candidateFile) + serializeIntegrationAccountFile(candidateFile) + saveWorkspaceToken(workspace.id, apiKey) if ( legacyWorkspace && legacyWorkspace.organizationName === workspace.organizationName && @@ -600,7 +810,8 @@ export async function connect( upsertWorkspace(workspace, { select: true }) return { ok: true, viewer: workspace, workspace } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to validate API key' + const message = + error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Failed to validate API key' return { ok: false, error: message } } } @@ -659,6 +870,10 @@ export async function testConnection( ): Promise< { ok: true; viewer: LinearViewer; workspace: LinearWorkspace } | { ok: false; error: string } > { + getWorkspaceFile() + if (workspaceFileReadError) { + return { ok: false, error: workspaceFileReadError.message } + } const resolvedWorkspaceId = resolveWorkspaceId(workspaceId) if (!resolvedWorkspaceId) { return { ok: false, error: 'No API key stored.' } @@ -667,7 +882,7 @@ export async function testConnection( try { token = loadToken({ force: true, workspaceId: resolvedWorkspaceId }) } catch (error) { - const message = error instanceof Error ? error.message : 'Test failed' + const message = error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Test failed' return { ok: false, error: message } } if (!token) { @@ -679,6 +894,10 @@ export async function testConnection( const me = await client.viewer const org = await me.organization const workspace = workspaceFromLinearData(me, org) + assertWorkspaceBounds(workspace) + const candidateFile = workspaceFileWithUpsert(workspace, { select: true }) + assertWorkspaceFileBounds(candidateFile) + serializeIntegrationAccountFile(candidateFile) if (resolvedWorkspaceId === LEGACY_WORKSPACE_ID) { replaceLegacyWorkspace(workspace, token) } else { @@ -690,7 +909,7 @@ export async function testConnection( if (isAuthError(error)) { clearToken(resolvedWorkspaceId) } - const message = error instanceof Error ? error.message : 'Test failed' + const message = error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Test failed' return { ok: false, error: message } } } diff --git a/src/main/linear/issue-context-fanout-reads.ts b/src/main/linear/issue-context-fanout-reads.ts new file mode 100644 index 00000000000..4b744d97022 --- /dev/null +++ b/src/main/linear/issue-context-fanout-reads.ts @@ -0,0 +1,96 @@ +import { runBoundedIntegrationSettledFanout } from '../integration-fanout' +import { boundedIntegrationErrorLog } from '../integration-error-message' +import type { LinearClientForWorkspace } from './client' +import { LinearAgentAccessError } from './issue-context-errors' +import { workspaceFailure, type WorkspaceReadFailure } from './issue-context-fanout' + +export async function readLinearIssueWorkspaceFanout( + entries: LinearClientForWorkspace[], + selection: string | 'all', + readWorkspace: (entry: LinearClientForWorkspace) => Promise, + initialFailures: WorkspaceReadFailure[] = [] +): Promise<{ results: TResult[]; truncated: boolean }> { + if (selection !== 'all') { + const selected = await readWorkspace(entries[0]) + return { results: selected ? [selected] : [], truncated: false } + } + + const fanout = await runBoundedIntegrationSettledFanout(entries, readWorkspace, (result) => + result ? [result] : [] + ) + const results: TResult[] = [] + const failures: LinearAgentAccessError[] = initialFailures.map((failure) => failure.error) + + for (const result of fanout.results) { + if (result.status === 'fulfilled') { + if (result.value) { + results.push(result.value) + } + continue + } + if (result.reason instanceof LinearAgentAccessError) { + failures.push(result.reason) + } + console.warn('[linear] agent issue read failed:', boundedIntegrationErrorLog(result.reason)) + } + + if (fanout.truncated) { + console.warn('[linear] Agent issue fan-out exceeded its aggregate result budget; truncating.') + } + if (results.length === 0 && failures[0] && !fanout.truncated) { + throw failures[0] + } + return { results, truncated: fanout.truncated } +} + +export async function readLinearSearchWorkspaceFanout( + entries: LinearClientForWorkspace[], + workspaceId: string | 'all' | undefined, + readWorkspace: (entry: LinearClientForWorkspace) => Promise, + initialFailures: WorkspaceReadFailure[] = [] +): Promise<{ + results: TResult[][] + failures: WorkspaceReadFailure[] + truncated: boolean +}> { + if (workspaceId && workspaceId !== 'all') { + return { + results: [await readWorkspace(entries[0])], + failures: [], + truncated: false + } + } + + const fanout = await runBoundedIntegrationSettledFanout( + entries, + readWorkspace, + (results) => results + ) + const attemptedWorkspaceCount = fanout.attemptedCount + initialFailures.length + const results: TResult[][] = [] + const failures: WorkspaceReadFailure[] = [...initialFailures] + for (let index = 0; index < fanout.results.length; index += 1) { + const result = fanout.results[index] + if (result.status === 'fulfilled') { + results.push(result.value) + continue + } + if (result.reason instanceof LinearAgentAccessError) { + failures.push(workspaceFailure(entries[index].workspace, result.reason)) + } + console.warn('[linear] agent search failed:', boundedIntegrationErrorLog(result.reason)) + } + + if (fanout.truncated) { + console.warn('[linear] Agent search fan-out exceeded its aggregate result budget; truncating.') + } + if ( + results.length === 0 && + failures.length === attemptedWorkspaceCount && + failures[0] && + !fanout.truncated + ) { + throw failures[0].error + } + return { results, failures, truncated: fanout.truncated } +} diff --git a/src/main/linear/issue-context-fanout.ts b/src/main/linear/issue-context-fanout.ts index 484eec818f7..17fe07ead15 100644 --- a/src/main/linear/issue-context-fanout.ts +++ b/src/main/linear/issue-context-fanout.ts @@ -1,5 +1,6 @@ import type { LinearErrorCode, LinearWorkspaceCandidate } from '../../shared/linear-agent-access' import type { LinearWorkspace } from '../../shared/types' +import { boundedIntegrationErrorLog } from '../integration-error-message' import { getClients, getStatus, type LinearClientForWorkspace } from './client' import { LinearAgentAccessError, @@ -35,7 +36,10 @@ export function getFanoutClientEntries(): { } catch (error) { const failure = workspaceFailure(workspace, toLinearAccessError(error)) failures.push(failure) - console.warn('[linear] agent workspace credential read failed:', error) + console.warn( + '[linear] agent workspace credential read failed:', + boundedIntegrationErrorLog(error) + ) } } return { entries, failures } diff --git a/src/main/linear/issue-context-pagination.test.ts b/src/main/linear/issue-context-pagination.test.ts new file mode 100644 index 00000000000..b10b36c859a --- /dev/null +++ b/src/main/linear/issue-context-pagination.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' +import { IntegrationPaginationBudget } from '../integration-pagination-budget' +import { readConnectionPages } from './issue-context-pagination' + +describe('Linear issue context pagination', () => { + it('stops before retaining a page that exceeds the aggregate byte budget', async () => { + const loadConnection = vi + .fn() + .mockResolvedValueOnce({ + nodes: [{ id: 'first', body: 'small' }], + pageInfo: { hasNextPage: true, endCursor: 'next' } + }) + .mockResolvedValueOnce({ + nodes: [{ id: 'second', body: 'x'.repeat(100) }], + pageInfo: { hasNextPage: false, endCursor: null } + }) + const budget = new IntegrationPaginationBudget({ + maxPages: 10, + maxItems: 10, + maxRetainedBytes: 64 + }) + + await expect(readConnectionPages(10, loadConnection, budget)).resolves.toEqual({ + nodes: [{ id: 'first', body: 'small' }], + hasMore: true + }) + expect(loadConnection).toHaveBeenCalledTimes(2) + }) + + it('reports truncation when a backend over-delivers the requested page', async () => { + await expect( + readConnectionPages(1, async () => ({ + nodes: [{ id: 'first' }, { id: 'omitted' }], + pageInfo: { hasNextPage: false, endCursor: null } + })) + ).resolves.toEqual({ + nodes: [{ id: 'first' }], + hasMore: true + }) + }) +}) diff --git a/src/main/linear/issue-context-pagination.ts b/src/main/linear/issue-context-pagination.ts index d8c5f239ac9..ad0c5639628 100644 --- a/src/main/linear/issue-context-pagination.ts +++ b/src/main/linear/issue-context-pagination.ts @@ -1,4 +1,5 @@ import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear-issue-read-limits' +import { IntegrationPaginationBudget } from '../integration-pagination-budget' export type LinearPageVariables = { first: number; after?: string } @@ -12,7 +13,8 @@ export type LinearConnection = { export async function readConnectionPages( limit: number, - loadConnection: (page: LinearPageVariables) => Promise> + loadConnection: (page: LinearPageVariables) => Promise>, + budget = new IntegrationPaginationBudget() ): Promise<{ nodes: T[]; hasMore: boolean }> { const nodes: T[] = [] let after: string | undefined @@ -24,13 +26,21 @@ export async function readConnectionPages( const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - nodes.length) const connection = await loadConnection(after ? { first, after } : { first }) const pageNodes = connection?.nodes ?? [] - nodes.push(...pageNodes.slice(0, limit - nodes.length)) - hasMore = Boolean(connection?.pageInfo?.hasNextPage) + const retainedPageNodes = pageNodes.slice(0, limit - nodes.length) + hasMore = + pageNodes.length > retainedPageNodes.length || Boolean(connection?.pageInfo?.hasNextPage) + if (!budget.admitPage(retainedPageNodes)) { + return { nodes, hasMore: true } + } + nodes.push(...retainedPageNodes) const nextCursor = connection?.pageInfo?.endCursor ?? undefined if (!hasMore || !nextCursor || nextCursor === after || pageNodes.length === 0) { break } + if (!budget.canRequestPage) { + return { nodes, hasMore: true } + } after = nextCursor } diff --git a/src/main/linear/linear-project-request-coalescer.ts b/src/main/linear/linear-project-request-coalescer.ts new file mode 100644 index 00000000000..35a7983e690 --- /dev/null +++ b/src/main/linear/linear-project-request-coalescer.ts @@ -0,0 +1,33 @@ +export const LINEAR_PROJECT_MAX_INFLIGHT_KEYS = 256 +export const LINEAR_PROJECT_MAX_INFLIGHT_KEY_BYTES = 16 * 1024 + +export class LinearProjectRequestCoalescer { + private readonly inFlight = new Map>() + + coalesce(key: string, load: () => Promise, force = false): Promise { + const existing = this.inFlight.get(key) as Promise | undefined + if (existing && !force) { + return existing + } + + const loaded = load() + const mayTrack = + Buffer.byteLength(key, 'utf8') <= LINEAR_PROJECT_MAX_INFLIGHT_KEY_BYTES && + (this.inFlight.has(key) || this.inFlight.size < LINEAR_PROJECT_MAX_INFLIGHT_KEYS) + if (!mayTrack) { + return loaded + } + + const tracked = loaded.finally(() => { + if (this.inFlight.get(key) === tracked) { + this.inFlight.delete(key) + } + }) + this.inFlight.set(key, tracked) + return tracked + } + + get trackedRequestCount(): number { + return this.inFlight.size + } +} diff --git a/src/main/linear/linear-team-pages.test.ts b/src/main/linear/linear-team-pages.test.ts new file mode 100644 index 00000000000..af4329c0780 --- /dev/null +++ b/src/main/linear/linear-team-pages.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { + INTEGRATION_PAGINATION_MAX_ITEMS, + INTEGRATION_PAGINATION_MAX_PAGES, + IntegrationPaginationLimitError +} from '../integration-pagination-budget' +import { fetchAllTeamLabels } from './linear-team-pages' + +function label(index: number) { + return { id: `label-${index}`, name: `Label ${index}`, color: '#888888' } +} + +function cumulativeLabelPage(totalPages: number, advertisesOneMore = false) { + let pageNumber = 1 + const page = { + nodes: [label(1)], + pageInfo: { hasNextPage: totalPages > 1 || advertisesOneMore }, + fetchNext: vi.fn(async () => { + pageNumber += 1 + page.nodes.push(label(pageNumber)) + page.pageInfo.hasNextPage = pageNumber < totalPages || advertisesOneMore + return page + }) + } + return page +} + +describe('bounded Linear team metadata pagination', () => { + it('preserves cumulative node order through the exact page boundary', async () => { + const page = cumulativeLabelPage(INTEGRATION_PAGINATION_MAX_PAGES) + + const labels = await fetchAllTeamLabels({ labels: async () => page }) + expect(labels).toHaveLength(INTEGRATION_PAGINATION_MAX_PAGES) + expect(labels[0]).toMatchObject({ id: 'label-1' }) + expect(labels.at(-1)).toMatchObject({ id: `label-${INTEGRATION_PAGINATION_MAX_PAGES}` }) + expect(page.fetchNext).toHaveBeenCalledTimes(INTEGRATION_PAGINATION_MAX_PAGES - 1) + }) + + it('rejects page +1 before asking the SDK to materialize it', async () => { + const page = cumulativeLabelPage(INTEGRATION_PAGINATION_MAX_PAGES, true) + + await expect(fetchAllTeamLabels({ labels: async () => page })).rejects.toThrow( + IntegrationPaginationLimitError + ) + expect(page.fetchNext).toHaveBeenCalledTimes(INTEGRATION_PAGINATION_MAX_PAGES - 1) + }) + + it('admits the exact item boundary and rejects item +1', async () => { + const exact = { + nodes: Array.from({ length: INTEGRATION_PAGINATION_MAX_ITEMS }, (_, index) => label(index)), + pageInfo: { hasNextPage: false }, + fetchNext: vi.fn() + } + const over = { + ...exact, + nodes: [...exact.nodes, label(INTEGRATION_PAGINATION_MAX_ITEMS)] + } + + await expect(fetchAllTeamLabels({ labels: async () => exact })).resolves.toHaveLength( + INTEGRATION_PAGINATION_MAX_ITEMS + ) + await expect(fetchAllTeamLabels({ labels: async () => over })).rejects.toThrow( + IntegrationPaginationLimitError + ) + }) +}) diff --git a/src/main/linear/linear-team-pages.ts b/src/main/linear/linear-team-pages.ts index 1da7fd6242f..e3c9f318b7e 100644 --- a/src/main/linear/linear-team-pages.ts +++ b/src/main/linear/linear-team-pages.ts @@ -1,5 +1,9 @@ import type { LinearLabel, LinearMember, LinearTeam, LinearWorkflowState } from '../../shared/types' import { buildLinearTeamUrl } from '../../shared/linear-links' +import { + IntegrationPaginationBudget, + IntegrationPaginationLimitError +} from '../integration-pagination-budget' import type { LinearClientForWorkspace } from './client' const TEAM_PAGE_SIZE = 100 @@ -32,13 +36,26 @@ type TeamStateNode = { position: number } +async function fetchBoundedConnection( + page: LinearConnectionPage +): Promise> { + const budget = new IntegrationPaginationBudget() + while (true) { + budget.assertCumulativePage(page.nodes) + if (!page.pageInfo.hasNextPage) { + return page + } + if (!budget.canRequestPage) { + throw new IntegrationPaginationLimitError() + } + await page.fetchNext() + } +} + export async function fetchAllTeamsForWorkspace( entry: LinearClientForWorkspace ): Promise { - let page = await entry.client.teams({ first: TEAM_PAGE_SIZE }) - while (page.pageInfo.hasNextPage) { - await page.fetchNext() - } + const page = await fetchBoundedConnection(await entry.client.teams({ first: TEAM_PAGE_SIZE })) return page.nodes.map((t) => ({ id: t.id, workspaceId: entry.workspace.id, @@ -56,10 +73,7 @@ export async function fetchAllTeamsForWorkspace( export async function fetchAllTeamStates(team: { states: (variables?: { first?: number }) => Promise> }): Promise { - const states = await team.states({ first: TEAM_PAGE_SIZE }) - while (states.pageInfo.hasNextPage) { - await states.fetchNext() - } + const states = await fetchBoundedConnection(await team.states({ first: TEAM_PAGE_SIZE })) return states.nodes .map((s) => ({ id: s.id, @@ -74,20 +88,14 @@ export async function fetchAllTeamStates(team: { export async function fetchAllTeamLabels(team: { labels: (variables?: { first?: number }) => Promise> }): Promise { - const labels = await team.labels({ first: TEAM_PAGE_SIZE }) - while (labels.pageInfo.hasNextPage) { - await labels.fetchNext() - } + const labels = await fetchBoundedConnection(await team.labels({ first: TEAM_PAGE_SIZE })) return labels.nodes.map((l) => ({ id: l.id, name: l.name, color: l.color })) } export async function fetchAllTeamMembers(team: { members: (variables?: { first?: number }) => Promise> }): Promise { - const members = await team.members({ first: TEAM_PAGE_SIZE }) - while (members.pageInfo.hasNextPage) { - await members.fetchNext() - } + const members = await fetchBoundedConnection(await team.members({ first: TEAM_PAGE_SIZE })) return members.nodes.map((m) => ({ id: m.id, displayName: m.displayName, diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index 5945c3fc95b..d32289ed4fc 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -16,6 +16,15 @@ import { LINEAR_ISSUE_API_PAGE_SIZE_MAX, clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' +import { + boundedIntegrationErrorLog, + boundedIntegrationErrorMessage +} from '../integration-error-message' +import { runBoundedIntegrationFanout } from '../integration-fanout' +import { + INTEGRATION_PAGINATION_MAX_ITEMS, + IntegrationPaginationBudget +} from '../integration-pagination-budget' import { acquire, clearToken, @@ -24,6 +33,7 @@ import { release, type LinearClientForWorkspace } from './client' +import { LinearProjectRequestCoalescer } from './linear-project-request-coalescer' type LinearRawVariables = Record @@ -508,7 +518,7 @@ const CUSTOM_VIEW_PROJECTS_QUERY = ` } ` -const inFlight = new Map>() +const requestCoalescer = new LinearProjectRequestCoalescer() const LINEAR_PROJECT_API_PAGE_SIZE_MAX = 50 function clampLimit(limit = 20): number { @@ -516,17 +526,7 @@ function clampLimit(limit = 20): number { } function coalesce(key: string, load: () => Promise, force = false): Promise { - const existing = inFlight.get(key) as Promise | undefined - if (existing && !force) { - return existing - } - const promise = load().finally(() => { - if (inFlight.get(key) === promise) { - inFlight.delete(key) - } - }) - inFlight.set(key, promise) - return promise + return requestCoalescer.coalesce(key, load, force) } function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId { @@ -547,7 +547,7 @@ function workspaceError(entry: LinearClientForWorkspace, error: unknown): Linear } const record = error as { name?: string; message?: string; status?: number; response?: unknown } - const message = record.message || 'Linear request failed.' + const message = boundedIntegrationErrorMessage(record.message || 'Linear request failed.') const status = typeof record.status === 'number' ? record.status @@ -629,14 +629,15 @@ function mapProjectForWorkspace( priorityLabel: project.priorityLabel ?? null, lead: mapUser(project.lead), members: project.members?.nodes + ?.slice(0, 10) ?.map(mapUser) .filter((user): user is LinearProjectMemberSummary => !!user), - teams: project.teams?.nodes?.map((team) => ({ + teams: project.teams?.nodes?.slice(0, 10).map((team) => ({ id: team.id, name: team.name ?? '', key: team.key ?? undefined })), - labels: project.labels?.nodes?.map((label) => ({ + labels: project.labels?.nodes?.slice(0, 20).map((label) => ({ id: label.id, name: label.name ?? '', color: label.color ?? undefined @@ -661,7 +662,7 @@ function mapProjectDetailForWorkspace( ): LinearProjectDetail { return { ...mapProjectForWorkspace(entry, project), - milestones: project.projectMilestones?.nodes?.map((milestone) => ({ + milestones: project.projectMilestones?.nodes?.slice(0, 20).map((milestone) => ({ id: milestone.id, name: milestone.name ?? '', status: milestone.status ?? undefined, @@ -669,6 +670,7 @@ function mapProjectDetailForWorkspace( progress: milestone.progress ?? null })), resources: project.externalLinks?.nodes + ?.slice(0, 20) ?.filter((link) => link.url) .map((link) => ({ id: link.id, @@ -694,7 +696,7 @@ function mapIssueForWorkspace( entry: LinearClientForWorkspace, issue: LinearIssueNode ): LinearIssue { - const labelNodes = issue.labels?.nodes ?? [] + const labelNodes = (issue.labels?.nodes ?? []).slice(0, LINEAR_ISSUE_API_PAGE_SIZE_MAX) return { id: issue.id, identifier: issue.identifier, @@ -712,7 +714,9 @@ function mapIssueForWorkspace( key: issue.team?.key ?? '' }, labels: labelNodes.map((label) => label.name), - labelIds: issue.labelIds ?? labelNodes.map((label) => label.id), + labelIds: + issue.labelIds?.slice(0, INTEGRATION_PAGINATION_MAX_ITEMS) ?? + labelNodes.map((label) => label.id), assignee: mapUser(issue.assignee), estimate: issue.estimate ?? null, priority: issue.priority, @@ -777,6 +781,7 @@ async function readIssueConnectionPages( }) => Promise | null | undefined> ): Promise> { const items: LinearIssue[] = [] + const budget = new IntegrationPaginationBudget() let after: string | undefined let hasMore = false @@ -785,12 +790,22 @@ async function readIssueConnectionPages( // Orca reads must follow cursors to show more than one backend page. const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) const connection = await loadConnection(after ? { first, after } : { first }) - const nodes = connection?.nodes ?? [] - items.push(...nodes.map((issue) => mapIssueForWorkspace(entry, issue))) - hasMore = Boolean(connection?.pageInfo?.hasNextPage) + const pageNodes = connection?.nodes ?? [] + const nodes = pageNodes.slice(0, first) + const pageItems = nodes.map((issue) => mapIssueForWorkspace(entry, issue)) + hasMore = pageNodes.length > nodes.length || Boolean(connection?.pageInfo?.hasNextPage) + if (!budget.admitPage(pageItems)) { + console.warn('[linear] Project issue list exceeded its retained result budget; truncating.') + return { items, hasMore: true } + } + items.push(...pageItems) const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { + if (!hasMore || !nextCursor || nextCursor === after || pageNodes.length === 0) { + break + } + if (!budget.canRequestPage) { + console.warn('[linear] Project issue list reached its retained result budget; truncating.') break } after = nextCursor @@ -813,8 +828,9 @@ async function readCollection( return { items: [] } } - const results = await Promise.all( - entries.map(async (entry) => { + const fanout = await runBoundedIntegrationFanout( + entries, + async (entry) => { await acquire() try { return await load(entry) @@ -822,7 +838,7 @@ async function readCollection( if (isAuthError(error)) { clearToken(entry.workspace.id) } else { - console.warn('[linear] project/view read failed:', error) + console.warn('[linear] project/view read failed:', boundedIntegrationErrorLog(error)) } if (shouldFailWholeRequest(workspaceId)) { throw error @@ -831,15 +847,22 @@ async function readCollection( } finally { release() } - }) + }, + (result) => [...result.items, ...(result.errors ?? [])] ) + if (fanout.truncated) { + console.warn( + '[linear] Cross-workspace project metadata exceeded its aggregate result budget; truncating.' + ) + } + const results = fanout.results return { items: results.flatMap((result) => result.items), errors: results.flatMap((result) => result.errors ?? []).length ? results.flatMap((result) => result.errors ?? []) : undefined, - hasMore: results.some((result) => result.hasMore) + hasMore: fanout.truncated || results.some((result) => result.hasMore) } }, force @@ -875,9 +898,11 @@ export async function listProjects( LinearRawVariables >(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables) const connection = trimmed ? result.data?.searchProjects : result.data?.projects + const rawProjects = connection?.nodes ?? [] + const projects = rawProjects.slice(0, first) return { - items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), - hasMore: !!connection?.pageInfo?.hasNextPage + items: projects.map((project) => mapProjectForWorkspace(entry, project)), + hasMore: rawProjects.length > projects.length || !!connection?.pageInfo?.hasNextPage } }, force @@ -907,6 +932,7 @@ export async function listProjectsByExactName( await acquire() try { const matches: LinearProjectSummary[] = [] + const budget = new IntegrationPaginationBudget() let after: string | undefined while (true) { const result = await entry.client.client.rawRequest< @@ -918,17 +944,31 @@ export async function listProjectsByExactName( ...(after ? { after } : {}) }) const connection = result.data?.searchProjects - for (const project of connection?.nodes ?? []) { + const rawNodes = connection?.nodes ?? [] + const nodes = rawNodes.slice(0, LINEAR_PROJECT_API_PAGE_SIZE_MAX) + if (!budget.admitPage(nodes)) { + console.warn('[linear] Project search exceeded its retained result budget; truncating.') + break + } + for (const project of nodes) { if (project.name.trim().toLowerCase() === normalized) { matches.push(mapProjectForWorkspace(entry, project)) } } + if (rawNodes.length > nodes.length) { + console.warn('[linear] Project search returned more rows than requested; truncating.') + break + } const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if (connection?.pageInfo?.hasNextPage === true && !budget.canRequestPage) { + console.warn('[linear] Project search reached its retained result budget; truncating.') + break + } if ( connection?.pageInfo?.hasNextPage !== true || !nextCursor || nextCursor === after || - (connection.nodes ?? []).length === 0 + nodes.length === 0 ) { break } @@ -1015,7 +1055,7 @@ export async function createProject( clearToken(entry.workspace.id) throw error } - const message = error instanceof Error ? error.message : String(error) + const message = boundedIntegrationErrorMessage(error) return { ok: false, error: message } } finally { release() @@ -1073,6 +1113,7 @@ export async function listProjectTeams( return [] } const teams: NonNullable = [] + const budget = new IntegrationPaginationBudget() let after: string | undefined await acquire() try { @@ -1090,15 +1131,30 @@ export async function listProjectTeams( throw new Error('Project was not found') } const connection = project.teams - const nodes = connection?.nodes ?? [] - teams.push( - ...nodes.map((team) => ({ + const rawNodes = connection?.nodes ?? [] + const nodes = rawNodes.slice(0, LINEAR_PROJECT_API_PAGE_SIZE_MAX) + if (!budget.admitPage(nodes)) { + console.warn( + '[linear] Project teams exceeded their retained result budget; truncating.' + ) + break + } + for (const team of nodes) { + teams.push({ id: team.id, name: team.name ?? '', key: team.key ?? undefined - })) - ) + }) + } + if (rawNodes.length > nodes.length) { + console.warn('[linear] Project teams returned more rows than requested; truncating.') + break + } const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if (connection?.pageInfo?.hasNextPage === true && !budget.canRequestPage) { + console.warn('[linear] Project teams reached their retained result budget; truncating.') + break + } if ( !connection?.pageInfo?.hasNextPage || !nextCursor || @@ -1141,11 +1197,13 @@ export async function listCustomViews( LinearRawVariables >(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' }) const connection = result.data?.customViews + const rawViews = connection?.nodes ?? [] + const views = rawViews.slice(0, first) return { - items: (connection?.nodes ?? []) + items: views .map((view) => mapCustomViewForWorkspace(entry, view)) .filter((view): view is LinearCustomViewSummary => !!view && view.model === model), - hasMore: !!connection?.pageInfo?.hasNextPage + hasMore: rawViews.length > views.length || !!connection?.pageInfo?.hasNextPage } }, force @@ -1251,9 +1309,11 @@ export async function listCustomViewProjects( throw new Error('Custom view does not contain projects') } const connection = view?.projects + const rawProjects = connection?.nodes ?? [] + const projects = rawProjects.slice(0, first) return { - items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), - hasMore: !!connection?.pageInfo?.hasNextPage + items: projects.map((project) => mapProjectForWorkspace(entry, project)), + hasMore: rawProjects.length > projects.length || !!connection?.pageInfo?.hasNextPage } }, force diff --git a/src/main/local-downloaded-folder-promotion-budget.test.ts b/src/main/local-downloaded-folder-promotion-budget.test.ts new file mode 100644 index 00000000000..b0f11ec1645 --- /dev/null +++ b/src/main/local-downloaded-folder-promotion-budget.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + LocalDownloadedFolderPromotionBudget, + type LocalDownloadedFolderPromotionLimits +} from './local-downloaded-folder-promotion-budget' + +const limits: LocalDownloadedFolderPromotionLimits = { + maximumEntries: 2, + maximumDepth: 2, + maximumPathBytes: 4, + maximumRetainedPathBytes: 8 +} + +describe('LocalDownloadedFolderPromotionBudget', () => { + it('accepts every exact boundary', () => { + const budget = new LocalDownloadedFolderPromotionBudget(limits) + + budget.recordEntry('12', '34', 2) + budget.recordEntry('56', '78', 2) + }) + + it.each([ + [ + 'entries', + (budget: LocalDownloadedFolderPromotionBudget) => { + budget.recordEntry('', '', 0) + budget.recordEntry('', '', 0) + budget.recordEntry('', '', 0) + } + ], + ['depth', (budget: LocalDownloadedFolderPromotionBudget) => budget.recordEntry('', '', 3)], + ['path', (budget: LocalDownloadedFolderPromotionBudget) => budget.recordEntry('12345', '', 0)] + ] as const)('rejects one unit beyond the %s boundary', (reason, exceed) => { + expect(() => exceed(new LocalDownloadedFolderPromotionBudget(limits))).toThrow( + expect.objectContaining({ reason }) + ) + }) + + it('rejects one unit beyond the retained-path boundary', () => { + const budget = new LocalDownloadedFolderPromotionBudget({ + ...limits, + maximumEntries: 3 + }) + budget.recordEntry('12', '34', 0) + budget.recordEntry('56', '78', 0) + + expect(() => budget.recordEntry('9', '', 0)).toThrow( + expect.objectContaining({ reason: 'paths' }) + ) + }) +}) diff --git a/src/main/local-downloaded-folder-promotion-budget.ts b/src/main/local-downloaded-folder-promotion-budget.ts new file mode 100644 index 00000000000..e7821d31313 --- /dev/null +++ b/src/main/local-downloaded-folder-promotion-budget.ts @@ -0,0 +1,49 @@ +export type LocalDownloadedFolderPromotionLimits = { + maximumEntries: number + maximumDepth: number + maximumPathBytes: number + maximumRetainedPathBytes: number +} + +export const LOCAL_DOWNLOADED_FOLDER_PROMOTION_LIMITS: LocalDownloadedFolderPromotionLimits = { + maximumEntries: 50_000, + maximumDepth: 64, + maximumPathBytes: 64 * 1024, + maximumRetainedPathBytes: 16 * 1024 * 1024 +} + +export class LocalDownloadedFolderPromotionCapacityError extends Error { + constructor(readonly reason: 'entries' | 'depth' | 'path' | 'paths') { + super(`Downloaded folder promotion exceeds the ${reason} limit`) + this.name = 'LocalDownloadedFolderPromotionCapacityError' + } +} + +export class LocalDownloadedFolderPromotionBudget { + private entries = 0 + private retainedPathBytes = 0 + + constructor(private readonly limits = LOCAL_DOWNLOADED_FOLDER_PROMOTION_LIMITS) {} + + recordEntry(sourcePath: string, destinationPath: string, depth: number): void { + if (depth > this.limits.maximumDepth) { + throw new LocalDownloadedFolderPromotionCapacityError('depth') + } + this.entries += 1 + if (this.entries > this.limits.maximumEntries) { + throw new LocalDownloadedFolderPromotionCapacityError('entries') + } + const sourceBytes = Buffer.byteLength(sourcePath, 'utf8') + const destinationBytes = Buffer.byteLength(destinationPath, 'utf8') + if ( + sourceBytes > this.limits.maximumPathBytes || + destinationBytes > this.limits.maximumPathBytes + ) { + throw new LocalDownloadedFolderPromotionCapacityError('path') + } + this.retainedPathBytes += sourceBytes + destinationBytes + if (this.retainedPathBytes > this.limits.maximumRetainedPathBytes) { + throw new LocalDownloadedFolderPromotionCapacityError('paths') + } + } +} diff --git a/src/main/local-downloaded-folder-promotion-traversal.ts b/src/main/local-downloaded-folder-promotion-traversal.ts new file mode 100644 index 00000000000..a7681c225e2 --- /dev/null +++ b/src/main/local-downloaded-folder-promotion-traversal.ts @@ -0,0 +1,51 @@ +import type { Dirent } from 'node:fs' +import { opendir } from 'node:fs/promises' +import { join } from 'node:path' +import { LocalDownloadedFolderPromotionBudget } from './local-downloaded-folder-promotion-budget' + +export async function readPromotionDirectoryEntries( + sourcePath: string, + destinationPath: string, + budget: LocalDownloadedFolderPromotionBudget, + depth: number +): Promise { + const entries: Dirent[] = [] + const directory = await opendir(sourcePath) + try { + for await (const entry of directory) { + budget.recordEntry(join(sourcePath, entry.name), join(destinationPath, entry.name), depth + 1) + entries.push(entry) + } + } finally { + await directory.close().catch(() => undefined) + } + return entries.sort((a, b) => a.name.localeCompare(b.name)) +} + +export async function assertPromotionTreeWithinCapacity( + sourceRoot: string, + destinationRoot: string, + signal?: AbortSignal +): Promise { + const budget = new LocalDownloadedFolderPromotionBudget() + const pending = [{ sourcePath: sourceRoot, destinationPath: destinationRoot, depth: 0 }] + while (pending.length > 0) { + signal?.throwIfAborted() + const current = pending.pop()! + const directory = await opendir(current.sourcePath) + try { + for await (const entry of directory) { + signal?.throwIfAborted() + const sourcePath = join(current.sourcePath, entry.name) + const destinationPath = join(current.destinationPath, entry.name) + const depth = current.depth + 1 + budget.recordEntry(sourcePath, destinationPath, depth) + if (entry.isDirectory()) { + pending.push({ sourcePath, destinationPath, depth }) + } + } + } finally { + await directory.close().catch(() => undefined) + } + } +} diff --git a/src/main/local-downloaded-folder-promotion.test.ts b/src/main/local-downloaded-folder-promotion.test.ts index 57d82edd6e2..47168f19408 100644 --- a/src/main/local-downloaded-folder-promotion.test.ts +++ b/src/main/local-downloaded-folder-promotion.test.ts @@ -19,6 +19,7 @@ import { copyLocalDownloadedFileNoClobber, publishLocalDownloadedFileNoClobber } from './local-downloaded-folder-promotion' +import { LOCAL_DOWNLOADED_FOLDER_PROMOTION_LIMITS } from './local-downloaded-folder-promotion-budget' describe('promoteLocalDownloadedFolder', () => { const roots: string[] = [] @@ -115,6 +116,30 @@ describe('promoteLocalDownloadedFolder', () => { await expect(readdir(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) }) + it('rejects excessive depth before claiming the destination', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) + roots.push(root) + const tempPath = join(root, '.transfer.download') + const destinationPath = join(root, 'downloaded') + await mkdir(tempPath) + let nested = tempPath + for ( + let depth = 0; + depth <= LOCAL_DOWNLOADED_FOLDER_PROMOTION_LIMITS.maximumDepth; + depth += 1 + ) { + nested = join(nested, 'd') + await mkdir(nested) + } + + await expect(promoteLocalDownloadedFolder(tempPath, destinationPath)).rejects.toMatchObject({ + reason: 'depth' + }) + + await expect(lstat(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(lstat(nested)).resolves.toMatchObject({ isDirectory: expect.any(Function) }) + }) + it('rolls back unchanged entries after a mid-publication failure', async () => { const { tempPath, destinationPath } = await createPaths() await writeFile(join(tempPath, 'a-first.txt'), 'remote') diff --git a/src/main/local-downloaded-folder-promotion.ts b/src/main/local-downloaded-folder-promotion.ts index f5ba45f7a5c..9c962d57e75 100644 --- a/src/main/local-downloaded-folder-promotion.ts +++ b/src/main/local-downloaded-folder-promotion.ts @@ -1,8 +1,12 @@ -import { fstatSync, lstatSync } from 'node:fs' -import { link, lstat, mkdir, open, readdir, rm, rmdir, unlink } from 'node:fs/promises' -import type { BigIntStats } from 'node:fs' +import { fstatSync, lstatSync, type BigIntStats } from 'node:fs' +import { link, lstat, mkdir, open, rm, rmdir, unlink } from 'node:fs/promises' import type { FileHandle } from 'node:fs/promises' import { join } from 'node:path' +import { LocalDownloadedFolderPromotionBudget } from './local-downloaded-folder-promotion-budget' +import { + assertPromotionTreeWithinCapacity, + readPromotionDirectoryEntries +} from './local-downloaded-folder-promotion-traversal' const LOCAL_COPY_CHUNK_BYTES = 1024 * 1024 @@ -210,6 +214,8 @@ async function publishDirectoryNoClobber( sourcePath: string, destinationPath: string, publishedEntries: PublishedEntry[], + budget: LocalDownloadedFolderPromotionBudget, + depth: number, signal?: AbortSignal ): Promise { await mkdir(destinationPath, { recursive: false }) @@ -217,9 +223,7 @@ async function publishDirectoryNoClobber( // claim so no async gap exists before rollback ownership is registered. const destinationStats = lstatSync(destinationPath, { bigint: true }) publishedEntries.push(publishedEntryFromStats('directory', destinationPath, destinationStats)) - const entries = (await readdir(sourcePath, { withFileTypes: true })).toSorted((a, b) => - a.name.localeCompare(b.name) - ) + const entries = await readPromotionDirectoryEntries(sourcePath, destinationPath, budget, depth) for (const entry of entries) { signal?.throwIfAborted() const sourceEntryPath = join(sourcePath, entry.name) @@ -229,6 +233,8 @@ async function publishDirectoryNoClobber( sourceEntryPath, destinationEntryPath, publishedEntries, + budget, + depth + 1, signal ) } else if (entry.isFile()) { @@ -251,12 +257,21 @@ export async function promoteLocalDownloadedFolder( destinationPath: string, signal?: AbortSignal ): Promise { + signal?.throwIfAborted() + await assertPromotionTreeWithinCapacity(tempPath, destinationPath, signal) signal?.throwIfAborted() const publishedEntries: PublishedEntry[] = [] try { // Why: Node has no portable atomic no-replace directory rename. Claiming // the destination first preserves no-clobber while promotion stays local. - await publishDirectoryNoClobber(tempPath, destinationPath, publishedEntries, signal) + await publishDirectoryNoClobber( + tempPath, + destinationPath, + publishedEntries, + new LocalDownloadedFolderPromotionBudget(), + 0, + signal + ) } catch (error) { await rollbackPublishedEntries(publishedEntries) if (isEEXIST(error)) { diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index 496d88b0eae..1523fbdf503 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { execFileMock, lstatMock, readFileMock, rmMock } = vi.hoisted(() => ({ +const { execFileMock, lstatMock, readNodeFileWithinLimitMock, rmMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), lstatMock: vi.fn(), - readFileMock: vi.fn(), + readNodeFileWithinLimitMock: vi.fn(), rmMock: vi.fn() })) @@ -13,10 +13,13 @@ vi.mock('node:child_process', () => ({ vi.mock('node:fs/promises', () => ({ lstat: lstatMock, - readFile: readFileMock, rm: rmMock })) +vi.mock('../shared/memory-safety/node-bounded-file-reader', () => ({ + readNodeFileWithinLimit: readNodeFileWithinLimitMock +})) + import { getLocalWorktreePathAccess, removeLocalWorktreePath, @@ -49,7 +52,7 @@ describe('local worktree filesystem runtime access', () => { beforeEach(() => { execFileMock.mockReset() lstatMock.mockReset() - readFileMock.mockReset() + readNodeFileWithinLimitMock.mockReset() rmMock.mockReset() completeExecFile() }) @@ -61,7 +64,9 @@ describe('local worktree filesystem runtime access', () => { it('uses host filesystem operations when no WSL distro is selected', async () => { lstatMock.mockResolvedValue({ type: 'file' }) - readFileMock.mockResolvedValue('gitdir: ../.git/worktrees/feature') + readNodeFileWithinLimitMock.mockResolvedValue({ + buffer: Buffer.from('gitdir: ../.git/worktrees/feature') + }) const access = getLocalWorktreePathAccess() await access.statPath('C:\\repo\\.git') @@ -69,7 +74,7 @@ describe('local worktree filesystem runtime access', () => { await removeLocalWorktreePath('C:\\repo\\feature') expect(lstatMock).toHaveBeenCalledWith('C:\\repo\\.git') - expect(readFileMock).toHaveBeenCalledWith('C:\\repo\\.git', 'utf8') + expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith('C:\\repo\\.git', 64 * 1024) expect(rmMock).toHaveBeenCalledWith( toHostRemovalPath('C:\\repo\\feature'), expect.objectContaining({ @@ -165,6 +170,8 @@ describe('local worktree filesystem runtime access', () => { expect.objectContaining({ encoding: 'utf8' }), expect.any(Function) ) + const readArgs = execFileMock.mock.calls[1]?.[1] as string[] + expect(readArgs.at(-1)).toContain('head -c 65537 --') const removeArgs = execFileMock.mock.calls[2]?.[1] as string[] expect(removeArgs.at(-1)).toContain('rm -rf --') expect(removeArgs.at(-1)).toContain( @@ -174,6 +181,17 @@ describe('local worktree filesystem runtime access', () => { }) }) + it('rejects oversized WSL Git pointer output', async () => { + await withPlatform('win32', async () => { + completeExecFile('x'.repeat(64 * 1024 + 1)) + const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) + + await expect(access.readPath('/home/me/repo/.git')).rejects.toThrow( + 'Worktree Git pointer exceeds the safe read limit' + ) + }) + }) + it('reports missing WSL stat targets with an ENOENT-shaped error', async () => { await withPlatform('win32', async () => { failExecFile(Object.assign(new Error('missing'), { code: 2 })) diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index aafb5ea93fa..2201c5a92cc 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process' import type { RmOptions } from 'node:fs' -import { lstat, readFile, rm } from 'node:fs/promises' +import { lstat, rm } from 'node:fs/promises' import { win32 } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { @@ -9,7 +9,12 @@ import { quotePosixShell } from '../shared/wsl-login-shell-command' import { toLinuxPath } from './wsl' -import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof' +import { + MAX_WORKTREE_GIT_POINTER_BYTES, + type ReadPath, + type StatPath +} from './worktree-orphan-gitdir-proof' +import { readNodeFileWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' export type LocalWorktreeFilesystemOptions = { wslDistro?: string @@ -37,13 +42,13 @@ function shouldUseWslFilesystem(options: LocalWorktreeFilesystemOptions): boolea function execFileText( file: string, args: string[], - options: { timeout: number } + options: { maxBuffer?: number; timeout: number } ): Promise { return new Promise((resolve, reject) => { execFile( file, args, - { encoding: 'utf8', timeout: options.timeout }, + { encoding: 'utf8', timeout: options.timeout, maxBuffer: options.maxBuffer }, (error, stdout, stderr) => { if (error) { reject(error) @@ -97,7 +102,10 @@ export function getLocalWorktreePathAccess( if (!shouldUseWslFilesystem(options) || !distro) { return { statPath: lstat, - readPath: (path) => readFile(path, 'utf8') + readPath: async (path) => + (await readNodeFileWithinLimit(path, MAX_WORKTREE_GIT_POINTER_BYTES)).buffer.toString( + 'utf8' + ) } } @@ -120,7 +128,26 @@ export function getLocalWorktreePathAccess( }, readPath: async (path) => { const target = quotePosixShell(toLinuxPath(path)) - const { stdout } = await runWslLoginShellCommand(distro, `cat -- ${target}`) + const { stdout } = await execFileText( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'sh', + '-lc', + escapeWslShCommandForWindows( + buildWslLoginShellCommand(`head -c ${MAX_WORKTREE_GIT_POINTER_BYTES + 1} -- ${target}`) + ) + ], + { + timeout: WSL_FILE_OPERATION_TIMEOUT_MS, + maxBuffer: (MAX_WORKTREE_GIT_POINTER_BYTES + 1) * 2 + } + ) + if (Buffer.byteLength(stdout, 'utf8') > MAX_WORKTREE_GIT_POINTER_BYTES) { + throw new Error('Worktree Git pointer exceeds the safe read limit') + } return stdout } } diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index 63f8026b870..e8992b3acc9 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -653,6 +653,53 @@ describe('collectMemorySnapshot', () => { expectProcessSweepCount(2) }) + it('bounds retained worktree histories and recovers an evicted active history', async () => { + mockPsResponse('') + const { + collectMemorySnapshot, + getMemoryHistoryWorktreeCountForTests, + MEMORY_HISTORY_MAX_WORKTREES + } = await loadCollector() + const registrations = Array.from({ length: MEMORY_HISTORY_MAX_WORKTREES }, (_, index) => ({ + ptyId: `pty-${index}`, + worktreeId: `repo::/worktree-${index}`, + sessionId: `session-${index}`, + paneKey: `pane-${index}`, + pid: null + })) + listRegisteredPtysMock.mockReturnValue(registrations) + + await collectMemorySnapshot(emptyStore) + const atBoundary = await collectMemorySnapshot(emptyStore) + + expect(atBoundary.worktrees.every((worktree) => worktree.history.length === 2)).toBe(true) + expect(getMemoryHistoryWorktreeCountForTests()).toBe(MEMORY_HISTORY_MAX_WORKTREES) + + listRegisteredPtysMock.mockReturnValue([ + ...registrations, + { + ptyId: 'pty-overflow', + worktreeId: 'repo::/worktree-overflow', + sessionId: 'session-overflow', + paneKey: 'pane-overflow', + pid: null + } + ]) + const overflow = await collectMemorySnapshot(emptyStore) + + expect(overflow.worktrees).toHaveLength(MEMORY_HISTORY_MAX_WORKTREES + 1) + expect(overflow.worktrees[0].history).toEqual([0]) + expect(getMemoryHistoryWorktreeCountForTests()).toBe(MEMORY_HISTORY_MAX_WORKTREES) + + listRegisteredPtysMock.mockReturnValue([registrations[0]]) + const reactivated = await collectMemorySnapshot(emptyStore) + const retained = await collectMemorySnapshot(emptyStore) + + expect(reactivated.worktrees[0].history).toEqual([0]) + expect(retained.worktrees[0].history).toEqual([0, 0]) + expect(getMemoryHistoryWorktreeCountForTests()).toBe(MEMORY_HISTORY_MAX_WORKTREES) + }) + it('uses host process RSS for Electron app metrics when available', async () => { mockPsResponse(['10 1 1.5 111', '20 10 2.5 222', '30 10 3.5 333'].join('\n')) appMetricsMock.mockReturnValue([ diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index 171c07f175b..527af81e87b 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -126,6 +126,7 @@ function emptySnapshot(): MemorySnapshot { const APP_HISTORY_KEY = '__app__' const HISTORY_CAPACITY = 60 const HISTORY_STALE_MS = 10 * 60 * 1000 +export const MEMORY_HISTORY_MAX_WORKTREES = 1024 type HistoryRing = { samples: number[] @@ -138,18 +139,39 @@ function pushHistorySample(key: string, memoryBytes: number, now: number): void let ring = historyByKey.get(key) if (!ring) { ring = { samples: [], touchedAt: now } - historyByKey.set(key, ring) } ring.samples.push(memoryBytes) if (ring.samples.length > HISTORY_CAPACITY) { ring.samples.shift() } ring.touchedAt = now + // Why: worktree ids can churn faster than the stale TTL in a long-lived + // main process; recency ordering gives the history cache a hard ceiling. + historyByKey.delete(key) + historyByKey.set(key, ring) + trimWorktreeHistory() } -function readHistory(key: string): number[] { +function trimWorktreeHistory(): void { + const appEntryCount = historyByKey.has(APP_HISTORY_KEY) ? 1 : 0 + while (historyByKey.size - appEntryCount > MEMORY_HISTORY_MAX_WORKTREES) { + let oldestWorktreeKey: string | undefined + for (const key of historyByKey.keys()) { + if (key !== APP_HISTORY_KEY) { + oldestWorktreeKey = key + break + } + } + if (oldestWorktreeKey === undefined) { + break + } + historyByKey.delete(oldestWorktreeKey) + } +} + +function readHistory(key: string, currentSample?: number): number[] { const ring = historyByKey.get(key) - return ring ? [...ring.samples] : [] + return ring ? [...ring.samples] : currentSample === undefined ? [] : [currentSample] } function sweepStaleHistory(now: number): void { @@ -160,6 +182,11 @@ function sweepStaleHistory(now: number): void { } } +/** @internal — test-only */ +export function getMemoryHistoryWorktreeCountForTests(): number { + return historyByKey.size - (historyByKey.has(APP_HISTORY_KEY) ? 1 : 0) +} + // ─── Host process enumeration ─────────────────────────────────────── async function enumerateProcesses(): Promise { @@ -433,7 +460,9 @@ async function runSnapshot(store: MemorySnapshotStore): Promise const worktrees: WorktreeMemory[] = bucketList.map((b) => ({ ...b, - history: readHistory(b.worktreeId) + // Why: an over-cap active worktree still gets its current point even when + // an older retained ring had to be evicted during this same snapshot. + history: readHistory(b.worktreeId, b.memory) })) let sessionCpuTotal = 0 diff --git a/src/main/mimo/hook-service.test.ts b/src/main/mimo/hook-service.test.ts index e30e415ca97..f175debdffc 100644 --- a/src/main/mimo/hook-service.test.ts +++ b/src/main/mimo/hook-service.test.ts @@ -1,5 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -75,4 +84,47 @@ describe('MimoCodeHookService buildPtyEnv', () => { readFileSync(join(overlayHome, 'config', 'plugins', 'orca-mimocode-status.js'), 'utf8') ).toContain('/hook/mimo-code') }) + + it.skipIf(process.platform === 'win32')( + 'keeps a symlinked user plugins directory isolated from Orca writes', + () => { + const sourcePlugins = join(mimocodeHome, 'config', 'plugins') + const realPlugins = join(mimocodeHome, 'real-plugins') + rmSync(sourcePlugins, { recursive: true, force: true }) + mkdirSync(realPlugins) + writeFileSync(join(realPlugins, 'user-plugin.js'), 'USER PLUGIN') + symlinkSync(realPlugins, sourcePlugins, 'dir') + + const env = new MimoCodeHookService().buildPtyEnv('pty-1', mimocodeHome) + const overlayPlugins = join(env.MIMOCODE_HOME!, 'config', 'plugins') + + expect(existsSync(join(realPlugins, 'orca-mimocode-status.js'))).toBe(false) + expect(lstatSync(overlayPlugins).isSymbolicLink()).toBe(false) + expect(readFileSync(join(overlayPlugins, 'user-plugin.js'), 'utf8')).toBe('USER PLUGIN') + expect(readFileSync(join(overlayPlugins, 'orca-mimocode-status.js'), 'utf8')).toContain( + '/hook/mimo-code' + ) + } + ) + + it('falls back to the original home when the config plan exceeds capacity', async () => { + const mirroring = await import('../pty/config-overlay-mirroring') + const planSpy = vi.spyOn(mirroring, 'createConfigOverlayPlan').mockImplementation(() => { + throw new mirroring.ConfigOverlayCapacityError('entries', 4_097, 4_096) + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + expect(new MimoCodeHookService().buildPtyEnv('pty-1', mimocodeHome)).toEqual({ + MIMOCODE_HOME: mimocodeHome + }) + expect(warnSpy).toHaveBeenCalledWith( + '[mimocode-hooks] config overlay exceeded its memory limit; using the original MiMo home without Orca status integration' + ) + expect(readFileSync(join(mimocodeHome, 'config', 'mimocode.json'), 'utf8')).toBe( + '{"theme":"dark"}' + ) + } finally { + planSpy.mockRestore() + } + }) }) diff --git a/src/main/mimo/hook-service.ts b/src/main/mimo/hook-service.ts index 3f879d93063..7d5420ba2de 100644 --- a/src/main/mimo/hook-service.ts +++ b/src/main/mimo/hook-service.ts @@ -1,9 +1,14 @@ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { getOpenCodeFamilyPluginSource } from '../opencode/hook-service' -import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { + ConfigOverlayCapacityError, + applyConfigOverlayPlan, + createConfigOverlayPlan +} from '../pty/config-overlay-mirroring' +import { safeRemoveTree } from '../pty/overlay-mirror' const ORCA_MIMOCODE_PLUGIN_FILE = 'orca-mimocode-status.js' const MIMOCODE_HOOKS_DIR = 'mimocode-hooks' @@ -24,30 +29,9 @@ function resolveSourceConfigDir(existingHome: string | undefined): string | unde return existsSync(xdg) ? xdg : undefined } -function mirrorConfigDir(sourceConfigDir: string, targetConfigDir: string): void { - mkdirSync(targetConfigDir, { recursive: true }) - for (const entry of readdirSync(sourceConfigDir, { withFileTypes: true })) { - if (entry.name === 'plugins' && entry.isDirectory()) { - const overlayPlugins = join(targetConfigDir, 'plugins') - mkdirSync(overlayPlugins, { recursive: true }) - for (const pluginEntry of readdirSync(join(sourceConfigDir, 'plugins'), { - withFileTypes: true - })) { - if (pluginEntry.name === ORCA_MIMOCODE_PLUGIN_FILE) { - continue - } - mirrorEntry( - join(sourceConfigDir, 'plugins', pluginEntry.name), - join(overlayPlugins, pluginEntry.name) - ) - } - continue - } - mirrorEntry(join(sourceConfigDir, entry.name), join(targetConfigDir, entry.name)) - } -} - export class MimoCodeHookService { + private warnedOverlayCapacity = false + clearPty(_ptyId: string): void {} buildPtyEnv(_ptyId: string, existingMimocodeHome?: string): Record { @@ -61,16 +45,33 @@ export class MimoCodeHookService { const overlayConfig = join(home, 'config') const sourceConfig = resolveSourceConfigDir(existingMimocodeHome) if (sourceConfig) { - safeRemoveTree(overlayConfig) - mirrorConfigDir(sourceConfig, overlayConfig) + const plan = createConfigOverlayPlan(sourceConfig, { + reservedPluginFile: ORCA_MIMOCODE_PLUGIN_FILE + }) + if (!safeRemoveTree(overlayConfig)) { + throw new Error('Unable to clear the MiMo config overlay') + } + mkdirSync(overlayConfig, { recursive: true }) + applyConfigOverlayPlan(plan, overlayConfig) } const pluginsDir = join(home, 'config', 'plugins') mkdirSync(pluginsDir, { recursive: true }) - writeFileSync( - join(pluginsDir, ORCA_MIMOCODE_PLUGIN_FILE), - getOpenCodeFamilyPluginSource('/hook/mimo-code') - ) - } catch { + const pluginPath = join(pluginsDir, ORCA_MIMOCODE_PLUGIN_FILE) + try { + unlinkSync(pluginPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + writeFileSync(pluginPath, getOpenCodeFamilyPluginSource('/hook/mimo-code')) + } catch (error) { + if (!this.warnedOverlayCapacity && error instanceof ConfigOverlayCapacityError) { + this.warnedOverlayCapacity = true + console.warn( + '[mimocode-hooks] config overlay exceeded its memory limit; using the original MiMo home without Orca status integration' + ) + } return existingMimocodeHome ? { MIMOCODE_HOME: existingMimocodeHome } : {} } return { MIMOCODE_HOME: home } diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 33cf8c23823..bb23a88512e 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -3,7 +3,7 @@ import { homedir } from 'node:os' import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' -import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' +import { findFirstSessionFile } from '../ai-vault/session-scanner-discovery' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { findGrokChatHistoryBySessionId, @@ -102,11 +102,10 @@ async function resolveClaudeSessionFile( projectsDir: string ): Promise { const targetName = `${sessionId}.jsonl` - const files = await walkSessionFiles(projectsDir, 'claude', [], { + return findFirstSessionFile(projectsDir, { extensions: new Set(['.jsonl']), filePredicate: (path) => basename(path) === targetName }) - return files[0] ?? null } async function resolveCodexSessionFile( @@ -120,15 +119,15 @@ async function resolveCodexSessionFile( if (!existsSync(sessionsDir)) { continue } - const files = await walkSessionFiles(sessionsDir, 'codex', [], { + const file = await findFirstSessionFile(sessionsDir, { extensions: new Set(['.jsonl']), filePredicate: (path) => { const name = basename(path, extname(path)) return name === sessionId || name.endsWith(`-${sessionId}`) } }) - if (files[0]) { - return files[0] + if (file) { + return file } } return null diff --git a/src/main/native-chat/transcript-incremental-reader.test.ts b/src/main/native-chat/transcript-incremental-reader.test.ts new file mode 100644 index 00000000000..d97a264176f --- /dev/null +++ b/src/main/native-chat/transcript-incremental-reader.test.ts @@ -0,0 +1,185 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { + APPEND_BATCH_RETAINED_BYTE_LIMIT, + createIncrementalTranscriptState, + readIncrementalTranscriptMessages +} from './transcript-incremental-reader' +import { + estimateTranscriptMessageRetainedBytes, + MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES +} from './transcript-message-retention' +import { transcriptFallbackId } from './transcript-fallback-id' +import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES } from './transcript-tail-reader' + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('readIncrementalTranscriptMessages', () => { + it('keeps the newest shared-retention window for an initial snapshot without batching', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-incremental-')) + tempRoots.push(root) + const filePath = join(root, 'transcript.jsonl') + const extraMessages = 5 + await writeFile( + filePath, + Array.from( + { length: MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES + extraMessages }, + (_unused, index) => `row-${index}\n` + ).join('') + ) + const state = createIncrementalTranscriptState() + + const messages = await readIncrementalTranscriptMessages( + filePath, + state, + (line): NativeChatMessage => ({ + id: line, + role: 'user', + blocks: [{ type: 'text', text: line }], + timestamp: null, + source: 'transcript' + }) + ) + + expect(messages).toHaveLength(MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES) + expect(messages[0]?.id).toBe(`row-${extraMessages}`) + expect(messages.at(-1)?.id).toBe( + `row-${MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES + extraMessages - 1}` + ) + }) + + it('flushes append batches by retained bytes before the message-count limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-incremental-batch-bytes-')) + tempRoots.push(root) + const filePath = join(root, 'transcript.jsonl') + const payload = 'x'.repeat(1024 * 1024) + const lines = Array.from({ length: 5 }, (_unused, index) => `row-${index}:${payload}`) + await writeFile(filePath, `${lines.join('\n')}\n`) + const state = createIncrementalTranscriptState() + const batches: NativeChatMessage[][] = [] + const decode = (line: string): NativeChatMessage => ({ + id: line.slice(0, line.indexOf(':')), + role: 'user', + blocks: [{ type: 'text', text: line }], + timestamp: null, + source: 'transcript' + }) + + const remaining = await readIncrementalTranscriptMessages(filePath, state, decode, (messages) => + batches.push(messages) + ) + const delivered = [...batches.flat(), ...remaining] + + expect(batches.map((batch) => batch.length)).toEqual([3]) + expect(delivered.map((message) => message.id)).toEqual( + Array.from({ length: 5 }, (_unused, index) => `row-${index}`) + ) + for (const batch of [...batches, remaining]) { + const retainedBytes = batch.reduce( + (total, message) => + total + + estimateTranscriptMessageRetainedBytes( + lines.find((line) => line.startsWith(`${message.id}:`))!.length + ), + 0 + ) + expect(retainedBytes).toBeLessThanOrEqual(APPEND_BATCH_RETAINED_BYTE_LIMIT) + } + }) + + it('pauses an aggregate drain at a record boundary and resumes without gaps', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-incremental-drain-budget-')) + tempRoots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, 'row-0\nrow-1\nrow-2\nrow-3\nrow-4\n') + const state = createIncrementalTranscriptState() + const decode = (line: string): NativeChatMessage => ({ + id: line, + role: 'user', + blocks: [{ type: 'text', text: line }], + timestamp: null, + source: 'transcript' + }) + const drain = (): Promise => + readIncrementalTranscriptMessages(filePath, state, decode, () => {}, undefined, undefined, { + maxDrainRetainedBytes: 600 + }) + + const first = await drain() + const firstOffset = state.offset + const second = await drain() + const third = await drain() + + expect(first.map((message) => message.id)).toEqual(['row-0', 'row-1']) + expect(second.map((message) => message.id)).toEqual(['row-2', 'row-3']) + expect(third.map((message) => message.id)).toEqual(['row-4']) + expect(firstOffset).toBeGreaterThan(0) + expect(state.pendingRecord.byteLength).toBe(0) + }) + + it('advances one bounded record when the drain budget is smaller than that record', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-incremental-small-budget-')) + tempRoots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, 'row-0\nrow-1\n') + const state = createIncrementalTranscriptState() + const decode = (line: string): NativeChatMessage => ({ + id: line, + role: 'user', + blocks: [{ type: 'text', text: line }], + timestamp: null, + source: 'transcript' + }) + const drain = (): Promise => + readIncrementalTranscriptMessages(filePath, state, decode, () => {}, undefined, undefined, { + maxDrainRetainedBytes: 1 + }) + + const first = await drain() + const firstOffset = state.offset + const second = await drain() + const secondOffset = state.offset + const exhausted = await drain() + + expect(first.map((message) => message.id)).toEqual(['row-0']) + expect(second.map((message) => message.id)).toEqual(['row-1']) + expect(exhausted).toEqual([]) + expect(firstOffset).toBeGreaterThan(0) + expect(secondOffset).toBeGreaterThan(firstOffset) + }) + + it('keeps the exact fallback offset after discarding an oversized record', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-incremental-oversized-')) + tempRoots.push(root) + const filePath = join(root, 'transcript.jsonl') + const oversized = Buffer.alloc(MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES + 1, 0x78) + const valid = Buffer.from('valid\n') + await writeFile(filePath, Buffer.concat([oversized, Buffer.from('\n'), valid])) + const state = createIncrementalTranscriptState() + + const messages = await readIncrementalTranscriptMessages( + filePath, + state, + (line, fallbackId): NativeChatMessage => ({ + id: fallbackId, + role: 'user', + blocks: [{ type: 'text', text: line }], + timestamp: null, + source: 'transcript' + }) + ) + + expect(messages.map((message) => message.id)).toEqual([ + transcriptFallbackId(filePath, oversized.byteLength + 1) + ]) + expect(state.offset).toBe(oversized.byteLength + 1 + valid.byteLength) + expect(state.pendingRecord.byteLength).toBe(0) + }) +}) diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts index 8ad8cc335d1..1c1df1ddef5 100644 --- a/src/main/native-chat/transcript-incremental-reader.ts +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -1,27 +1,38 @@ import { open, stat } from 'node:fs/promises' import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { transcriptFallbackId } from './transcript-fallback-id' +import { TranscriptRecordBuffer } from './transcript-record-buffer' import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, type NativeChatLineDecoder } from './transcript-tail-reader' +import { + estimateTranscriptMessageRetainedBytes, + TranscriptMessageRetention +} from './transcript-message-retention' -const APPEND_BATCH_MESSAGE_LIMIT = 40 +export const APPEND_BATCH_MESSAGE_LIMIT = 40 +export const APPEND_BATCH_RETAINED_BYTE_LIMIT = 8 * 1024 * 1024 +export const INCREMENTAL_DRAIN_RETAINED_BYTE_LIMIT = 32 * 1024 * 1024 export type IncrementalTranscriptState = { offset: number - pendingChunks: Buffer[] + pendingRecord: TranscriptRecordBuffer pendingStart: number - pendingBytes: number - droppingOversizedRecord: boolean +} + +export function createIncrementalTranscriptState(): IncrementalTranscriptState { + return { + offset: 0, + pendingRecord: new TranscriptRecordBuffer(MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES), + pendingStart: 0 + } } export function resetIncrementalTranscriptState(state: IncrementalTranscriptState): void { state.offset = 0 - state.pendingChunks.length = 0 + state.pendingRecord.clear() state.pendingStart = 0 - state.pendingBytes = 0 - state.droppingOversizedRecord = false } export async function readIncrementalTranscriptMessages( @@ -30,27 +41,41 @@ export async function readIncrementalTranscriptMessages( decode: NativeChatLineDecoder, onBatch?: (messages: NativeChatMessage[]) => void, decodeLifecycle?: (line: string, fallbackId: string) => NativeChatTurnLifecycle | null, - onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void + onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void, + options: { maxDrainRetainedBytes?: number } = {} ): Promise { const end = (await stat(filePath)).size if (end <= state.offset) { return [] } const messages: NativeChatMessage[] = [] + let messageBatchBytes = 0 + let drainRetainedBytes = 0 + const requestedDrainLimit = options.maxDrainRetainedBytes + const maxDrainRetainedBytes = + Number.isSafeInteger(requestedDrainLimit) && (requestedDrainLimit ?? 0) > 0 + ? Math.min(INCREMENTAL_DRAIN_RETAINED_BYTE_LIMIT, requestedDrainLimit ?? 0) + : INCREMENTAL_DRAIN_RETAINED_BYTE_LIMIT + const retainedSnapshot = onBatch ? null : new TranscriptMessageRetention() const handle = await open(filePath, 'r') try { const stream = handle.createReadStream({ start: state.offset, end: end - 1, autoClose: false }) let absoluteOffset = state.offset - for await (const rawChunk of stream) { + streamChunks: for await (const rawChunk of stream) { const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) let segmentStart = 0 let newline = chunk.indexOf(0x0a) while (newline >= 0) { retainPart(chunk.subarray(segmentStart, newline)) - if (!state.droppingOversizedRecord) { - decodeLine() + if (!state.pendingRecord.isOversized && !decodeLine()) { + const retryOffset = state.pendingStart + resetPendingLine(retryOffset) + state.offset = retryOffset + break streamChunks } - resetPendingLine(absoluteOffset + newline + 1) + const nextOffset = absoluteOffset + newline + 1 + resetPendingLine(nextOffset) + state.offset = nextOffset segmentStart = newline + 1 newline = chunk.indexOf(0x0a, segmentStart) } @@ -60,51 +85,69 @@ export async function readIncrementalTranscriptMessages( absoluteOffset += chunk.length state.offset = absoluteOffset } - return messages + return retainedSnapshot?.values() ?? messages } finally { await handle.close() } function retainPart(part: Buffer): void { - if (state.droppingOversizedRecord) { - return - } - state.pendingBytes += part.length - if (state.pendingBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { - state.pendingChunks.length = 0 - state.droppingOversizedRecord = true - return - } - state.pendingChunks.push(part) + state.pendingRecord.append(part) } function resetPendingLine(nextStart: number): void { - state.pendingChunks.length = 0 - state.pendingBytes = 0 - state.droppingOversizedRecord = false + state.pendingRecord.clear() state.pendingStart = nextStart } - function decodeLine(): void { - let line = Buffer.concat(state.pendingChunks).toString('utf8') + function decodeLine(): boolean { + let line = state.pendingRecord.toString() if (line.endsWith('\r')) { line = line.slice(0, -1) } if (!line) { - return + return true } const fallbackId = transcriptFallbackId(filePath, state.pendingStart) + const message = decode(line, fallbackId) + const estimatedBytes = message + ? estimateTranscriptMessageRetainedBytes(state.pendingRecord.byteLength) + : 0 + if ( + message && + onBatch && + drainRetainedBytes > 0 && + estimatedBytes > maxDrainRetainedBytes - drainRetainedBytes + ) { + // Why: a budget below one bounded record must still advance instead of + // retrying that record forever. + return false + } const lifecycle = decodeLifecycle?.(line, fallbackId) if (lifecycle) { onLifecycle?.(lifecycle) } - const message = decode(line, fallbackId) if (!message) { - return + return true + } + if (retainedSnapshot) { + retainedSnapshot.add(message, state.pendingRecord.byteLength) + return true + } + drainRetainedBytes += estimatedBytes + if ( + onBatch && + messages.length > 0 && + estimatedBytes > APPEND_BATCH_RETAINED_BYTE_LIMIT - messageBatchBytes + ) { + onBatch(messages.splice(0)) + messageBatchBytes = 0 } messages.push(message) + messageBatchBytes += estimatedBytes if (onBatch && messages.length >= APPEND_BATCH_MESSAGE_LIMIT) { onBatch(messages.splice(0)) + messageBatchBytes = 0 } + return true } } diff --git a/src/main/native-chat/transcript-message-retention.test.ts b/src/main/native-chat/transcript-message-retention.test.ts new file mode 100644 index 00000000000..9dae78eb069 --- /dev/null +++ b/src/main/native-chat/transcript-message-retention.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { + MAX_NATIVE_CHAT_TRANSCRIPT_RETAINED_BYTES, + TranscriptMessageRetention +} from './transcript-message-retention' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + } +} + +describe('TranscriptMessageRetention', () => { + it('keeps the newest messages within both count and byte budgets', () => { + const retention = new TranscriptMessageRetention(2, 600) + + retention.add(message('one'), 10) + retention.add(message('two'), 10) + retention.add(message('three'), 10) + + expect(retention.values().map(({ id }) => id)).toEqual(['two', 'three']) + expect(retention.size).toBe(2) + expect(retention.retainedBytes).toBeLessThanOrEqual(600) + }) + + it('applies the production 64 MiB policy without retaining older oversized history', () => { + const retention = new TranscriptMessageRetention() + const sourceBytes = 2 * 1024 * 1024 + + for (let index = 0; index < 17; index += 1) { + retention.add(message(`message-${index}`), sourceBytes) + } + + expect(retention.values().map(({ id }) => id)).toEqual( + Array.from({ length: 15 }, (_unused, index) => `message-${index + 2}`) + ) + expect(retention.retainedBytes).toBeLessThanOrEqual(MAX_NATIVE_CHAT_TRANSCRIPT_RETAINED_BYTES) + }) +}) diff --git a/src/main/native-chat/transcript-message-retention.ts b/src/main/native-chat/transcript-message-retention.ts new file mode 100644 index 00000000000..aee50bacd55 --- /dev/null +++ b/src/main/native-chat/transcript-message-retention.ts @@ -0,0 +1,59 @@ +import type { NativeChatMessage } from '../../shared/native-chat-types' + +export const MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES = 50_000 +export const MAX_NATIVE_CHAT_TRANSCRIPT_RETAINED_BYTES = 64 * 1024 * 1024 + +type RetainedMessage = { + message: NativeChatMessage + estimatedBytes: number +} + +export function estimateTranscriptMessageRetainedBytes(sourceBytes: number): number { + return sourceBytes * 2 + 256 +} + +export class TranscriptMessageRetention { + private entries: (RetainedMessage | undefined)[] = [] + private head = 0 + private retained = 0 + + constructor( + private readonly maxMessages = MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES, + private readonly maxBytes = MAX_NATIVE_CHAT_TRANSCRIPT_RETAINED_BYTES + ) {} + + add(message: NativeChatMessage, sourceBytes: number): void { + const estimatedBytes = estimateTranscriptMessageRetainedBytes(sourceBytes) + this.entries.push({ message, estimatedBytes }) + this.retained += estimatedBytes + while (this.size > this.maxMessages || this.retained > this.maxBytes) { + const oldest = this.entries[this.head] + this.entries[this.head] = undefined + this.head += 1 + this.retained -= oldest?.estimatedBytes ?? 0 + } + if (this.head >= 1_024 && this.head * 2 >= this.entries.length) { + this.entries.splice(0, this.head) + this.head = 0 + } + } + + values(): NativeChatMessage[] { + const messages: NativeChatMessage[] = [] + for (let index = this.head; index < this.entries.length; index += 1) { + const entry = this.entries[index] + if (entry) { + messages.push(entry.message) + } + } + return messages + } + + get size(): number { + return this.entries.length - this.head + } + + get retainedBytes(): number { + return this.retained + } +} diff --git a/src/main/native-chat/transcript-read-admission.test.ts b/src/main/native-chat/transcript-read-admission.test.ts new file mode 100644 index 00000000000..fbb395d4fcf --- /dev/null +++ b/src/main/native-chat/transcript-read-admission.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { NativeChatTranscriptReadAdmission } from './transcript-read-admission' + +describe('NativeChatTranscriptReadAdmission', () => { + it('admits only reads that fit both concurrency and aggregate byte limits', async () => { + const admission = new NativeChatTranscriptReadAdmission(10, 2, 4) + const releaseSix = await admission.acquire(6) + const releaseFour = await admission.acquire(4) + let thirdAdmitted = false + const third = admission.acquire(1).then((release) => { + thirdAdmitted = true + return release + }) + + await Promise.resolve() + expect(thirdAdmitted).toBe(false) + expect(admission.activeCount).toBe(2) + expect(admission.retainedBytes).toBe(10) + expect(admission.queuedCount).toBe(1) + + releaseFour() + const releaseThird = await third + expect(thirdAdmitted).toBe(true) + expect(admission.retainedBytes).toBe(7) + + releaseSix() + releaseThird() + expect(admission.activeCount).toBe(0) + expect(admission.retainedBytes).toBe(0) + }) + + it('removes an aborted queued read and immediately reuses the slot', async () => { + const admission = new NativeChatTranscriptReadAdmission(10, 2, 1) + const releaseActive = await admission.acquire(10) + const controller = new AbortController() + const queued = admission.acquire(1, controller.signal) + + expect(admission.queuedCount).toBe(1) + controller.abort() + await expect(queued).rejects.toMatchObject({ name: 'AbortError' }) + expect(admission.queuedCount).toBe(0) + + const replacement = admission.acquire(1) + expect(admission.queuedCount).toBe(1) + releaseActive() + const releaseReplacement = await replacement + releaseReplacement() + }) + + it('bounds queued closures and releases reservations idempotently', async () => { + const admission = new NativeChatTranscriptReadAdmission(1, 1, 2) + const releaseActive = await admission.acquire(1) + const first = admission.acquire(1) + const second = admission.acquire(1) + + expect(() => admission.acquire(1)).toThrow('Too many queued native chat transcript reads') + releaseActive() + releaseActive() + const releaseFirst = await first + releaseFirst() + const releaseSecond = await second + releaseSecond() + + expect(admission.activeCount).toBe(0) + expect(admission.retainedBytes).toBe(0) + }) + + it('detaches abort listeners after admission', async () => { + const admission = new NativeChatTranscriptReadAdmission(1, 1, 1) + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + + const release = await admission.acquire(1, controller.signal) + + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + controller.abort() + expect(admission.activeCount).toBe(1) + release() + }) +}) diff --git a/src/main/native-chat/transcript-read-admission.ts b/src/main/native-chat/transcript-read-admission.ts new file mode 100644 index 00000000000..54a4a37e3e4 --- /dev/null +++ b/src/main/native-chat/transcript-read-admission.ts @@ -0,0 +1,144 @@ +import { INCREMENTAL_DRAIN_RETAINED_BYTE_LIMIT } from './transcript-incremental-reader' +import { MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES } from './transcript-tail-reader' + +export const MAX_NATIVE_CHAT_TRANSCRIPT_READ_CONCURRENCY = 8 +export const MAX_NATIVE_CHAT_TRANSCRIPT_READ_WAITERS = 256 +export const MAX_NATIVE_CHAT_TRANSCRIPT_PROCESS_RETAINED_BYTES = 128 * 1024 * 1024 +export const NATIVE_CHAT_TRANSCRIPT_PAGE_RESERVATION_BYTES = + MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES +export const NATIVE_CHAT_TRANSCRIPT_WATCH_DRAIN_RESERVATION_BYTES = + MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES + INCREMENTAL_DRAIN_RETAINED_BYTE_LIMIT + +type AdmissionWaiter = { + abort: (() => void) | null + bytes: number + reject: (error: Error) => void + resolve: (release: () => void) => void + signal?: AbortSignal +} + +function abortedReadError(): Error { + const error = new Error('Native chat transcript read was canceled') + error.name = 'AbortError' + return error +} + +export class NativeChatTranscriptReadAdmission { + private activeBytes = 0 + private activeReads = 0 + private readonly waiters: AdmissionWaiter[] = [] + + constructor( + private readonly maxBytes = MAX_NATIVE_CHAT_TRANSCRIPT_PROCESS_RETAINED_BYTES, + private readonly maxActive = MAX_NATIVE_CHAT_TRANSCRIPT_READ_CONCURRENCY, + private readonly maxWaiters = MAX_NATIVE_CHAT_TRANSCRIPT_READ_WAITERS + ) { + if ( + !Number.isSafeInteger(maxBytes) || + maxBytes < 0 || + !Number.isSafeInteger(maxActive) || + maxActive < 1 || + !Number.isSafeInteger(maxWaiters) || + maxWaiters < 0 + ) { + throw new RangeError('Invalid native chat transcript read admission limits') + } + } + + acquire(bytes: number, signal?: AbortSignal): Promise<() => void> { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.maxBytes) { + throw new RangeError('Native chat transcript read exceeds the process memory budget') + } + if (signal?.aborted) { + return Promise.reject(abortedReadError()) + } + const canAdmitImmediately = + this.waiters.length === 0 && + this.activeReads < this.maxActive && + bytes <= this.maxBytes - this.activeBytes + if (!canAdmitImmediately && this.waiters.length >= this.maxWaiters) { + throw new Error('Too many queued native chat transcript reads') + } + + return new Promise((resolve, reject) => { + const waiter: AdmissionWaiter = { + abort: null, + bytes, + reject, + resolve, + ...(signal ? { signal } : {}) + } + if (signal) { + waiter.abort = (): void => { + const index = this.waiters.indexOf(waiter) + if (index < 0) { + return + } + this.waiters.splice(index, 1) + signal.removeEventListener('abort', waiter.abort!) + waiter.abort = null + reject(abortedReadError()) + this.admitWaiters() + } + signal.addEventListener('abort', waiter.abort, { once: true }) + } + this.waiters.push(waiter) + this.admitWaiters() + }) + } + + get activeCount(): number { + return this.activeReads + } + + get queuedCount(): number { + return this.waiters.length + } + + get retainedBytes(): number { + return this.activeBytes + } + + private admitWaiters(): void { + while (this.waiters.length > 0) { + const waiter = this.waiters[0] + if (this.activeReads >= this.maxActive || waiter.bytes > this.maxBytes - this.activeBytes) { + return + } + this.waiters.shift() + if (waiter.signal && waiter.abort) { + waiter.signal.removeEventListener('abort', waiter.abort) + waiter.abort = null + } + this.activeReads += 1 + this.activeBytes += waiter.bytes + let released = false + waiter.resolve(() => { + if (released) { + return + } + released = true + this.activeReads -= 1 + this.activeBytes -= waiter.bytes + this.admitWaiters() + }) + } + } +} + +export const nativeChatTranscriptReadAdmission = new NativeChatTranscriptReadAdmission() + +export async function withNativeChatTranscriptWatchDrainAdmission( + signal: AbortSignal, + run: () => Promise +): Promise { + const release = await nativeChatTranscriptReadAdmission.acquire( + NATIVE_CHAT_TRANSCRIPT_WATCH_DRAIN_RESERVATION_BYTES, + signal + ) + try { + return await run() + } finally { + release() + } +} diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 8943c223dd2..9a828f0d291 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -13,9 +13,8 @@ import { readNativeChatTranscript, type ReadTranscriptResult } from './transcrip // share one sessionId yet resolve to DIFFERENT files (the same session resumed // into a second worktree, which writes a new transcript file), and a // sessionId-only key let one worktree's cached parse be served to another when -// their file mtimes momentarily coincided (#7326). The cache stores ONE -// canonical, unwindowed parse; windowing and per-surface truncation stay in the -// callers so the same parse is reused across all `limit` values and every client kind. +// their file mtimes momentarily coincided (#7326). The cache stores one +// canonical bounded parse; surface-specific windowing stays in callers. type CachedTranscript = { result: ReadTranscriptResult @@ -34,7 +33,7 @@ const cache = new Map() const MAX_CACHE_ENTRIES = 50 // Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool // results embed whole file contents, command output, and diffs), and each cached -// entry is the full unwindowed parse. The count cap alone let 50 such entries +// entry is a large bounded parse. The count cap alone let 50 such entries // retain multiple GB in the one process that now serves desktop + every paired // web/mobile client. Bound total cached file bytes too; we always keep the most- // recent entry (see setCached) so an active transcript is never re-parsed on @@ -79,9 +78,7 @@ async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: num } /** - * Read the full transcript for an agent + session, returning the cached parse on - * an mtime hit and re-reading (and re-caching) when the file changed. Returns the - * canonical, unwindowed result; callers apply their own windowing/truncation. + * Read the canonical bounded transcript for a session, refreshing on file changes. */ export async function readNativeChatTranscriptCached( agent: AgentType, diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index ff48548804d..06c1c3ec6d1 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -2,8 +2,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' import { readNativeChatTranscript } from './transcript-reader' import { + MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES, nativeChatLineDecoderForAgent, readNativeChatTranscriptTail, readNativeChatTranscriptTailFile @@ -332,4 +334,41 @@ describe('readNativeChatTranscriptTailFile', () => { expect(result.messages).toEqual([]) expect(result.hasMore).toBe(false) }) + + it('preserves the newest messages and a correct pagination boundary at the page byte cap', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-tail-page-bytes-')) + tempRoots.push(root) + const filePath = join(root, 'transcript.jsonl') + const payload = 'x'.repeat(1024 * 1024) + const ids = Array.from({ length: 10 }, (_unused, index) => `message-${index}`) + await writeFile(filePath, ids.map((id) => `${id}:${payload}\n`).join('')) + const decode = (line: string): NativeChatMessage => { + const id = line.slice(0, line.indexOf(':')) + return { + id, + role: 'user', + blocks: [{ type: 'text', text: line }], + timestamp: null, + source: 'transcript' + } + } + + const newest = await readNativeChatTranscriptTailFile(filePath, 100, decode, true) + const older = await readNativeChatTranscriptTailFile( + filePath, + 100, + decode, + true, + newest.beforeOffset + ) + + expect(newest.hasMore).toBe(true) + expect(newest.messages.length).toBeGreaterThan(0) + expect(newest.messages.length).toBeLessThan(ids.length) + expect(newest.messages.at(-1)?.id).toBe(ids.at(-1)) + expect([...older.messages, ...newest.messages].map((message) => message.id)).toEqual(ids) + expect(newest.messages.length * (payload.length * 2 + 256)).toBeLessThanOrEqual( + MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES + ) + }) }) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 86353604e20..55542e3a1d6 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -28,13 +28,7 @@ export type ReadTranscriptOptions = ResolveSessionFileOptions & { filePath?: string } -/** - * Read the ENTIRE Claude/Codex JSONL transcript for an agent + session id into - * the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies - * NO message cap. Unknown record types are skipped rather than throwing, so a - * single malformed/unrecognized line cannot fail the whole read. The per-line - * record-to-message mapping is shared with the live tailer. - */ +/** Reads the newest bounded transcript window; malformed records are skipped. */ export async function readNativeChatTranscript( agent: AgentType, sessionId: string, diff --git a/src/main/native-chat/transcript-record-buffer.test.ts b/src/main/native-chat/transcript-record-buffer.test.ts new file mode 100644 index 00000000000..6e5d4c3b735 --- /dev/null +++ b/src/main/native-chat/transcript-record-buffer.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { TranscriptRecordBuffer } from './transcript-record-buffer' + +describe('TranscriptRecordBuffer', () => { + it('preserves UTF-8 across 100,000 one-byte fragments', () => { + const expected = Buffer.from(`${'a'.repeat(99_996)}😀`) + const record = new TranscriptRecordBuffer(expected.byteLength) + + for (const byte of expected) { + record.append(Uint8Array.of(byte)) + } + + expect(record.byteLength).toBe(expected.byteLength) + expect(record.toString()).toBe(expected.toString()) + expect(record.isOversized).toBe(false) + }) + + it('drops retained storage at the byte cap while tracking the full record length', () => { + const record = new TranscriptRecordBuffer(4) + + record.append(Buffer.from('1234')) + record.append(Buffer.from('56789')) + record.append(Buffer.from('abc')) + + expect(record.byteLength).toBe(12) + expect(record.toString()).toBe('') + expect(record.isOversized).toBe(true) + + record.clear() + record.append(Buffer.from('ok')) + expect(record.toString()).toBe('ok') + }) +}) diff --git a/src/main/native-chat/transcript-record-buffer.ts b/src/main/native-chat/transcript-record-buffer.ts new file mode 100644 index 00000000000..e5f09bf326a --- /dev/null +++ b/src/main/native-chat/transcript-record-buffer.ts @@ -0,0 +1,44 @@ +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' + +export class TranscriptRecordBuffer { + private readonly retained = new GrowingByteBuffer() + private observedBytes = 0 + private oversized = false + + constructor(private readonly maxRetainedBytes: number) { + if (!Number.isSafeInteger(maxRetainedBytes) || maxRetainedBytes < 0) { + throw new RangeError('Transcript record limit must be a non-negative safe integer') + } + } + + append(part: Buffer | Uint8Array): void { + this.observedBytes += part.byteLength + if (this.oversized) { + return + } + if (this.observedBytes > this.maxRetainedBytes) { + this.retained.clear() + this.oversized = true + return + } + this.retained.append(part) + } + + clear(): void { + this.retained.clear() + this.observedBytes = 0 + this.oversized = false + } + + toString(): string { + return this.retained.toString('utf8') + } + + get byteLength(): number { + return this.observedBytes + } + + get isOversized(): boolean { + return this.oversized + } +} diff --git a/src/main/native-chat/transcript-stream-lines.test.ts b/src/main/native-chat/transcript-stream-lines.test.ts index 2bfa6f6f9d2..ddcb2a4edf3 100644 --- a/src/main/native-chat/transcript-stream-lines.test.ts +++ b/src/main/native-chat/transcript-stream-lines.test.ts @@ -1,6 +1,8 @@ import { Readable } from 'node:stream' import { describe, expect, it } from 'vitest' +import { transcriptFallbackId } from './transcript-fallback-id' import { decodeTranscriptStream } from './transcript-stream-lines' +import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES } from './transcript-tail-reader' const decode = (line: string, id: string) => ({ id, @@ -46,4 +48,52 @@ describe('decodeTranscriptStream', () => { expect(result.messages).toHaveLength(1) expect(result.consumedBytes).toBe(Buffer.byteLength(complete, 'utf8')) }) + + it('preserves multibyte UTF-8 split across one-byte stream chunks', async () => { + const line = `prefix-😀-suffix` + const encoded = Buffer.from(`${line}\n`) + const result = await decodeTranscriptStream( + Readable.from([...encoded].map((byte) => Uint8Array.of(byte))), + '/chat.jsonl', + 0, + decode, + false + ) + + expect(result.messages[0]?.blocks).toEqual([{ type: 'text', text: line }]) + expect(result.consumedBytes).toBe(encoded.byteLength) + }) + + it('drops an oversized record without retaining it or losing the next line', async () => { + const oversized = 'x'.repeat(MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES + 1) + const valid = '{"valid":true}' + const result = await decodeTranscriptStream( + Readable.from([oversized, `\n${valid}\n`]), + '/chat.jsonl', + 0, + decode, + true + ) + + expect(result.messages).toHaveLength(1) + expect(result.messages[0]?.blocks).toEqual([{ type: 'text', text: valid }]) + expect(result.messages[0]?.id).toBe( + transcriptFallbackId('/chat.jsonl', Buffer.byteLength(`${oversized}\n`, 'utf8')) + ) + expect(result.consumedBytes).toBe(Buffer.byteLength(`${oversized}\n${valid}\n`, 'utf8')) + }) + + it('consumes an oversized trailing record when trailing lines are included', async () => { + const oversized = 'x'.repeat(MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES + 1) + const result = await decodeTranscriptStream( + Readable.from([oversized]), + '/chat.jsonl', + 0, + decode, + true + ) + + expect(result.messages).toEqual([]) + expect(result.consumedBytes).toBe(Buffer.byteLength(oversized, 'utf8')) + }) }) diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts index a764941ad30..50339043dc5 100644 --- a/src/main/native-chat/transcript-stream-lines.ts +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -1,6 +1,9 @@ import type { Readable } from 'node:stream' import type { NativeChatMessage } from '../../shared/native-chat-types' import { transcriptFallbackId } from './transcript-fallback-id' +import { TranscriptRecordBuffer } from './transcript-record-buffer' +import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES } from './transcript-tail-reader' +import { TranscriptMessageRetention } from './transcript-message-retention' type TranscriptDecoder = (line: string, fallbackId: string) => NativeChatMessage | null @@ -11,28 +14,42 @@ export async function decodeTranscriptStream( decode: TranscriptDecoder, includeTrailingLine: boolean ): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { - const messages: NativeChatMessage[] = [] - let pending = '' + const messages = new TranscriptMessageRetention() + const pending = new TranscriptRecordBuffer(MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) let consumedBytes = 0 for await (const chunk of stream) { - pending += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8') - let newlineIndex = pending.indexOf('\n') + const bytes = + typeof chunk === 'string' + ? Buffer.from(chunk, 'utf8') + : Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk) + let segmentStart = 0 + let newlineIndex = bytes.indexOf(0x0a) while (newlineIndex !== -1) { - const segment = pending.slice(0, newlineIndex + 1) - decodeLine(segment.slice(0, -1), consumedBytes) - consumedBytes += Buffer.byteLength(segment, 'utf8') - pending = pending.slice(newlineIndex + 1) - newlineIndex = pending.indexOf('\n') + pending.append(bytes.subarray(segmentStart, newlineIndex)) + if (!pending.isOversized) { + decodeLine(pending.toString(), consumedBytes) + } + consumedBytes += pending.byteLength + 1 + pending.clear() + segmentStart = newlineIndex + 1 + newlineIndex = bytes.indexOf(0x0a, segmentStart) + } + if (segmentStart < bytes.length) { + pending.append(bytes.subarray(segmentStart)) } } - if (includeTrailingLine && pending.length > 0) { - decodeLine(pending, consumedBytes) - consumedBytes += Buffer.byteLength(pending, 'utf8') + if (includeTrailingLine && pending.byteLength > 0) { + if (!pending.isOversized) { + decodeLine(pending.toString(), consumedBytes) + } + consumedBytes += pending.byteLength } - return { messages, consumedBytes } + return { messages: messages.values(), consumedBytes } function decodeLine(rawLine: string, relativeOffset: number): void { const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine @@ -41,7 +58,7 @@ export async function decodeTranscriptStream( } const message = decode(line, transcriptFallbackId(filePath, start + relativeOffset)) if (message) { - messages.push(message) + messages.add(message, Buffer.byteLength(line, 'utf8')) } } } diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 2f874e95746..f852b881b16 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -16,8 +16,10 @@ import { nativeChatTurnLifecycleDecoderForAgent, type NativeChatTurnLifecycleDecoder } from './transcript-turn-lifecycle' +import { estimateTranscriptMessageRetainedBytes } from './transcript-message-retention' export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 +export const MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES = 16 * 1024 * 1024 const TAIL_CHUNK_BYTES = 64 * 1024 export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null @@ -59,6 +61,8 @@ export async function readNativeChatTranscriptTailFile( let lineBytes = 0 let lineOversized = false let lifecycle: NativeChatTurnLifecycle | undefined + let retainedMessageBytes = 0 + let pageBudgetReached = false try { const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) if (consumedTo === 0) { @@ -68,12 +72,16 @@ export async function readNativeChatTranscriptTailFile( const finalByte = Buffer.allocUnsafe(1) await handle.read(finalByte, 0, 1, consumedTo - 1) let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) - while (cursor > 0 && newestFirst.length <= limit) { + while (cursor > 0 && newestFirst.length <= limit && !pageBudgetReached) { const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) let segmentEnd = bytesRead - for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { + for ( + let index = bytesRead - 1; + index >= 0 && newestFirst.length <= limit && !pageBudgetReached; + index-- + ) { if (buffer[index] !== 0x0a) { continue } @@ -89,7 +97,7 @@ export async function readNativeChatTranscriptTailFile( } cursor = start } - if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) { + if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit && !pageBudgetReached) { decodeLine(0, newestFirst) } const chronological = newestFirst.toReversed() @@ -100,7 +108,7 @@ export async function readNativeChatTranscriptTailFile( messages: selected.map((entry) => entry.message), ...(lifecycle ? { lifecycle } : {}), consumedTo, - hasMore: limit > 0 && chronological.length > limit, + hasMore: limit > 0 && (chronological.length > limit || pageBudgetReached), beforeOffset: selected[0]?.offset ?? end } } finally { @@ -144,6 +152,12 @@ export async function readNativeChatTranscriptTailFile( lifecycle ??= decodeLifecycle?.(line, fallbackId) ?? undefined const message = decode(line, fallbackId) if (message) { + const estimatedBytes = estimateTranscriptMessageRetainedBytes(lineBytes) + if (estimatedBytes > MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES - retainedMessageBytes) { + pageBudgetReached = true + return + } + retainedMessageBytes += estimatedBytes messages.push({ message, offset: lineOffset }) } } diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts index ff1df71ee0f..59825e6a727 100644 --- a/src/main/native-chat/transcript-watch-engine.ts +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -6,11 +6,12 @@ import { type TranscriptFileVersion } from './transcript-file-version' import { + createIncrementalTranscriptState, readIncrementalTranscriptMessages, - resetIncrementalTranscriptState, - type IncrementalTranscriptState + resetIncrementalTranscriptState } from './transcript-incremental-reader' import { createTranscriptNativeWatcher } from './transcript-native-watcher' +import { withNativeChatTranscriptWatchDrainAdmission } from './transcript-read-admission' import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' import { nativeChatTurnLifecycleDecoderForAgent } from './transcript-turn-lifecycle' import type { @@ -61,13 +62,7 @@ export async function installTranscriptWatcher( const { onAppend, onInitialSnapshot, onReplace, initialLimit } = args const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) - const state: IncrementalTranscriptState = { - offset: 0, - pendingChunks: [], - pendingStart: 0, - pendingBytes: 0, - droppingOversizedRecord: false - } + const state = createIncrementalTranscriptState() let watchedVersion: TranscriptFileVersion | null = null let watchedBoundary = '' let initialDrain = true @@ -78,6 +73,7 @@ export async function installTranscriptWatcher( let reading = false let pendingReadRequested = false let rotationRetryCount = 0 + const drainController = new AbortController() function scheduleRotationRetry(): void { if (closed) { @@ -134,7 +130,7 @@ export async function installTranscriptWatcher( scheduleRotationRetry() } - async function drainOnce(): Promise { + async function drainOnceAdmitted(): Promise { const current = await readTranscriptFileVersion(filePath) const currentBoundary = await boundaryFingerprint(filePath, state.offset) if (closed) { @@ -239,6 +235,14 @@ export async function installTranscriptWatcher( await finishSuccessfulDrain(current) } + async function drainOnce(): Promise { + await withNativeChatTranscriptWatchDrainAdmission(drainController.signal, async () => { + if (!closed) { + await drainOnceAdmitted() + } + }) + } + async function drain(): Promise { if (closed) { return @@ -317,6 +321,7 @@ export async function installTranscriptWatcher( return } closed = true + drainController.abort() scheduler.dispose() nativeWatcher.dispose() activeWatcherCount-- diff --git a/src/main/network/macos-system-resolver-health.test.ts b/src/main/network/macos-system-resolver-health.test.ts index 804847e6bbf..62f706a980d 100644 --- a/src/main/network/macos-system-resolver-health.test.ts +++ b/src/main/network/macos-system-resolver-health.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawn } from 'node:child_process' import { classifyMacSystemResolverHealth, + MAC_RESOLVER_OUTPUT_MAX_BYTES, readCurrentProcessMacSystemResolverHealth } from './macos-system-resolver-health' import type { ChildProcessWithoutNullStreams } from 'node:child_process' @@ -110,6 +111,44 @@ resolver #1 expect(child.kill).toHaveBeenCalledTimes(1) }) + it('kills scutil and fails open before decoding oversized output', async () => { + mockPlatform('darwin') + const child = createMockScutilProcess() + vi.mocked(spawn).mockReturnValue(child) + + const healthPromise = readCurrentProcessMacSystemResolverHealth() + child.stdout.emit('data', 'x'.repeat(MAC_RESOLVER_OUTPUT_MAX_BYTES + 1)) + + await expect(healthPromise).resolves.toBe('unknown') + expect(child.kill).toHaveBeenCalledTimes(1) + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + }) + + it('accepts the exact combined output cap across many tiny chunks', async () => { + mockPlatform('darwin') + const child = createMockScutilProcess() + vi.mocked(spawn).mockReturnValue(child) + + const healthPromise = readCurrentProcessMacSystemResolverHealth() + const resolverOutput = Buffer.from( + 'DNS configuration\nresolver #1\n nameserver[0] : 1.1.1.1\n' + ) + child.stdout.emit('data', resolverOutput) + const tinyChunk = Buffer.alloc(16, 0x20) + let remainingBytes = MAC_RESOLVER_OUTPUT_MAX_BYTES - resolverOutput.byteLength + while (remainingBytes > 0) { + const chunk = + remainingBytes >= tinyChunk.byteLength ? tinyChunk : tinyChunk.subarray(0, remainingBytes) + child.stderr.emit('data', chunk) + remainingBytes -= chunk.byteLength + } + child.emit('close', 0) + + await expect(healthPromise).resolves.toBe('healthy') + expect(child.kill).not.toHaveBeenCalled() + }) + it('removes scutil listeners when the timeout settles before child close', async () => { vi.useFakeTimers() mockPlatform('darwin') diff --git a/src/main/network/macos-system-resolver-health.ts b/src/main/network/macos-system-resolver-health.ts index 9a3dd6f1eb6..43699c6494b 100644 --- a/src/main/network/macos-system-resolver-health.ts +++ b/src/main/network/macos-system-resolver-health.ts @@ -1,7 +1,9 @@ import { spawn } from 'node:child_process' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import type { SystemResolverHealth } from '../daemon/types' const MAC_RESOLVER_CHECK_TIMEOUT_MS = 1_500 +export const MAC_RESOLVER_OUTPUT_MAX_BYTES = 1024 * 1024 const MAC_NO_DNS_CONFIGURATION_RE = /\bNo DNS configuration available\b/i const MAC_DNS_CONFIGURATION_RE = /^DNS configuration\b/m const MAC_NAMESERVER_RE = /nameserver\[\d+\]\s*:/m @@ -24,18 +26,33 @@ export async function readCurrentProcessMacSystemResolverHealth( } return new Promise((resolve) => { - let stdout = '' - let stderr = '' + const stdout = new GrowingByteBuffer() + const stderr = new GrowingByteBuffer() + let outputBytes = 0 + let outputLimitExceeded = false let settled = false let timer: ReturnType | null = null const child = spawn('/usr/sbin/scutil', ['--dns'], { stdio: ['ignore', 'pipe', 'pipe'] }) - const onStdoutData = (chunk: string): void => { - stdout += chunk + const retainOutput = (target: GrowingByteBuffer, chunk: Buffer | string): void => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8') + if (bytes.byteLength > MAC_RESOLVER_OUTPUT_MAX_BYTES - outputBytes) { + outputLimitExceeded = true + stdout.clear() + stderr.clear() + child.kill() + finish() + return + } + target.append(bytes) + outputBytes += bytes.byteLength } - const onStderrData = (chunk: string): void => { - stderr += chunk + const onStdoutData = (chunk: Buffer | string): void => { + retainOutput(stdout, chunk) + } + const onStderrData = (chunk: Buffer | string): void => { + retainOutput(stderr, chunk) } const onAbort = (): void => { child.kill('SIGKILL') @@ -55,7 +72,11 @@ export async function readCurrentProcessMacSystemResolverHealth( child.off('error', finish) child.off('close', finish) signal?.removeEventListener('abort', onAbort) - resolve(classifyMacSystemResolverHealth(`${stdout}\n${stderr}`)) + resolve( + outputLimitExceeded + ? 'unknown' + : classifyMacSystemResolverHealth(`${stdout.takeString()}\n${stderr.takeString()}`) + ) } timer = setTimeout(() => { child.kill() @@ -63,8 +84,6 @@ export async function readCurrentProcessMacSystemResolverHealth( // cap the RPC even if scutil is slow to exit after SIGTERM. finish() }, MAC_RESOLVER_CHECK_TIMEOUT_MS) - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') child.stdout.on('data', onStdoutData) child.stderr.on('data', onStderrData) child.on('error', finish) diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index 347e1025893..0ba1c9ef342 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -1,7 +1,7 @@ // Bundle collection + upload tests. Upload helpers live outside bundle.ts, but // this suite keeps the diagnostic bundle contract in one place. -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' import { createServer, type RequestListener, type Server } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -344,6 +344,23 @@ describe('bundle — collection', () => { ).not.toThrow() }) + it('skips a sparse source file above the bounded read limit', () => { + writeFileSync(traceFile, '') + truncateSync(traceFile, 50 * 1024 * 1024 + 1) + + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + + expect(bundle.spanCount).toBe(0) + }) + it('skips valid JSON lines that are not span objects without throwing', () => { writeFileSync( traceFile, diff --git a/src/main/observability/bundle.ts b/src/main/observability/bundle.ts index 750f41fead9..bd71444184a 100644 --- a/src/main/observability/bundle.ts +++ b/src/main/observability/bundle.ts @@ -7,12 +7,13 @@ // upload body-size cap, token-handling discipline). import { randomBytes } from 'node:crypto' -import { readFileSync, statSync } from 'node:fs' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' import { MAX_BUNDLE_BYTES } from './diagnostic-bundle-limits' import { listRotatedFiles } from './local-file-sink' import { redactValue } from './redactor' const DEFAULT_LOOKBACK_MINUTES = 30 +const MAX_BUNDLE_SOURCE_FILE_BYTES = 50 * 1024 * 1024 export type CollectBundleOptions = { readonly traceFilePath: string @@ -103,12 +104,7 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { outer: for (const file of files) { let text: string try { - // stat first: the sink caps at 10 MB/file, so a tampered oversize file could panic-allocate on read. - const size = statSync(file).size - if (size > 50 * 1024 * 1024) { - continue - } - text = readFileSync(file, 'utf8') + text = readNodeFileSyncWithinLimit(file, MAX_BUNDLE_SOURCE_FILE_BYTES).buffer.toString('utf8') } catch { continue } diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 57a95df3a62..67d91e67f77 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -1,13 +1,15 @@ /* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ import { existsSync } from 'node:fs' -import { readdir, realpath, stat } from 'node:fs/promises' +import { realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, isAbsolute, join, posix, win32 } from 'node:path' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import Database from '../sqlite/sync-database' -import { columnExists, tableExists } from './schema-helpers' +import { listOpenCodeDatabaseFiles } from '../opencode/opencode-database-files' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' +import { getUsageHistoryRetainedBytes, UsageHistoryScanBudget } from '../usage-history-scan-budget' +import { iterateOpenCodeUsageRows, type OpenCodeUsageRow } from './sqlite-usage-row-stream' import type { OpenCodeUsageAttributedEvent, OpenCodeUsageDailyAggregate, @@ -27,34 +29,6 @@ export type OpenCodeUsageWorktreeRef = { displayName: string } -type OpenCodeUsageRow = { - id: string - session_id: string - time_created: number - time_updated: number | null - data: string - directory: string | null - title: string | null - worktree: string | null - session_model: string | null -} - -type OpenCodeSessionUsageRow = { - id: string - session_id: string - time_created: number - time_updated: number | null - directory: string | null - title: string | null - worktree: string | null - session_model: string | null - cost: number - tokens_input: number - tokens_output: number - tokens_reasoning: number - tokens_cache_read: number -} - const YIELD_EVERY_DATABASES = 2 function ensureNumber(value: unknown): number { @@ -111,11 +85,7 @@ export async function listOpenCodeDatabases(): Promise { } try { - const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true }) - return entries - .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) - .map((entry) => join(getOpenCodeDataDirectory(), entry.name)) - .sort() + return (await listOpenCodeDatabaseFiles(getOpenCodeDataDirectory())).paths } catch { return [] } @@ -148,140 +118,6 @@ async function yieldToEventLoop(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } -function getProjectJoin(db: Database.Database): string { - return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') - ? 'LEFT JOIN project p ON p.id = s.project_id' - : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' -} - -function getSessionModelSelect(db: Database.Database): string { - return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' -} - -function getAssistantSessionMessageCount(db: Database.Database): number { - if (!tableExists(db, 'session_message')) { - return 0 - } - const assistantPredicate = columnExists(db, 'session_message', 'type') - ? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL" - : "json_extract(data, '$.tokens.input') IS NOT NULL" - const row = db - .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) - .get() as { count?: number } | undefined - return row?.count ?? 0 -} - -function canReadSessionUsageRows(db: Database.Database): boolean { - if (!tableExists(db, 'session')) { - return false - } - return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( - (columnName) => columnExists(db, 'session', columnName) - ) -} - -function getSessionUsageRowCount(db: Database.Database): number { - if (!canReadSessionUsageRows(db)) { - return 0 - } - const row = db - .prepare( - `SELECT COUNT(*) AS count - FROM session - WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` - ) - .get() as { count?: number } | undefined - return row?.count ?? 0 -} - -function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { - const projectJoin = getProjectJoin(db) - const sessionModelSelect = getSessionModelSelect(db) - const rows = db - .prepare( - `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, - s.directory, s.title, p.worktree, ${sessionModelSelect}, - s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read - FROM session s - ${projectJoin} - WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0 - ORDER BY s.time_created, s.id` - ) - .all() as OpenCodeSessionUsageRow[] - - return rows.map((row) => ({ - id: row.id, - session_id: row.session_id, - time_created: row.time_created, - time_updated: row.time_updated, - directory: row.directory, - title: row.title, - worktree: row.worktree, - session_model: row.session_model, - data: JSON.stringify({ - cost: row.cost, - tokens: { - input: row.tokens_input, - output: row.tokens_output, - reasoning: row.tokens_reasoning, - total: row.tokens_input + row.tokens_output + row.tokens_reasoning, - cache: { - read: row.tokens_cache_read, - write: 0 - } - } - }) - })) -} - -function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] { - if (!tableExists(db, 'session')) { - return [] - } - - // Why: newer OpenCode DBs maintain session-level token/cost totals. Reading - // one aggregate row per session is faster than parsing every message blob. - if (getSessionUsageRowCount(db) > 0) { - return selectSessionUsageRows(db) - } - - const projectJoin = getProjectJoin(db) - const sessionModelSelect = getSessionModelSelect(db) - - if (getAssistantSessionMessageCount(db) > 0) { - const assistantPredicate = columnExists(db, 'session_message', 'type') - ? "sm.type = 'assistant'" - : "json_extract(sm.data, '$.tokens.input') IS NOT NULL" - return db - .prepare( - `SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data, - s.directory, s.title, p.worktree, ${sessionModelSelect} - FROM session_message sm - JOIN session s ON s.id = sm.session_id - ${projectJoin} - WHERE ${assistantPredicate} - ORDER BY sm.time_created, sm.id` - ) - .all() as OpenCodeUsageRow[] - } - - if (!tableExists(db, 'message')) { - return [] - } - - return db - .prepare( - `SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data, - s.directory, s.title, p.worktree, ${sessionModelSelect} - FROM message m - JOIN session s ON s.id = m.session_id - ${projectJoin} - WHERE json_extract(m.data, '$.role') = 'assistant' - ORDER BY m.time_created, m.id` - ) - .all() as OpenCodeUsageRow[] -} - function parseJsonObject(value: unknown): Record | null { if (typeof value === 'object' && value !== null && !Array.isArray(value)) { return value as Record @@ -841,11 +677,71 @@ function mergeDailyAggregates( } } +function claimOpenCodeUsageProjection( + budget: UsageHistoryScanBudget, + sessions: readonly OpenCodeUsageSession[], + dailyAggregates: readonly OpenCodeUsageDailyAggregate[] +): void { + for (const session of sessions) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + session.sessionId, + session.firstTimestamp, + session.lastTimestamp, + session.primaryModel, + session.primaryProjectLabel, + session.primaryWorktreeId, + session.primaryRepoId + ]) + ) + for (const location of session.locationBreakdown) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + location.locationKey, + location.projectLabel, + location.repoId, + location.worktreeId + ]) + ) + } + for (const model of session.modelBreakdown) { + budget.claimProjection(getUsageHistoryRetainedBytes([model.modelKey, model.modelLabel])) + } + for (const locationModel of session.locationModelBreakdown) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + locationModel.locationKey, + locationModel.modelKey, + locationModel.modelLabel, + locationModel.repoId, + locationModel.worktreeId + ]) + ) + } + } + for (const daily of dailyAggregates) { + budget.claimProjection( + getUsageHistoryRetainedBytes([ + daily.day, + daily.model, + daily.projectKey, + daily.projectLabel, + daily.repoId, + daily.worktreeId + ]) + ) + } +} + export async function parseOpenCodeUsageDatabase( dbPath: string, worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[], - options: { claimSession?: (sessionId: string) => boolean } = {} + options: { + claimSession?: (sessionId: string) => boolean + budget?: UsageHistoryScanBudget + } = {} ): Promise { + const budget = options.budget ?? new UsageHistoryScanBudget() const processedDatabase = await getProcessedDatabaseInfo(dbPath) const db = new Database(dbPath, { readonly: true, fileMustExist: true }) try { @@ -853,7 +749,7 @@ export async function parseOpenCodeUsageDatabase( const events: OpenCodeUsageAttributedEvent[] = [] const claimedBySessionId = new Map() let hasDeferredClaims = false - for (const row of selectUsageRows(db)) { + for (const row of iterateOpenCodeUsageRows(db)) { const parsed = parseOpenCodeUsageRow(row) if (!parsed) { continue @@ -862,6 +758,7 @@ export async function parseOpenCodeUsageDatabase( // each session must be counted from exactly one database (#8006). let owned = claimedBySessionId.get(parsed.sessionId) if (owned === undefined) { + budget.claimOwnershipKey(parsed.sessionId) owned = options.claimSession ? options.claimSession(parsed.sessionId) : true claimedBySessionId.set(parsed.sessionId, owned) } @@ -871,12 +768,27 @@ export async function parseOpenCodeUsageDatabase( } const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees) if (attributed) { + budget.claimRecord( + getUsageHistoryRetainedBytes([ + attributed.sessionId, + attributed.timestamp, + attributed.cwd, + attributed.model, + attributed.day, + attributed.projectKey, + attributed.projectLabel, + attributed.repoId, + attributed.worktreeId + ]) + ) events.push(attributed) } } + const aggregates = aggregateOpenCodeUsage(events) + claimOpenCodeUsageProjection(budget, aggregates.sessions, aggregates.dailyAggregates) return { ...processedDatabase, - ...aggregateOpenCodeUsage(events), + ...aggregates, ownedSessionIds: [...claimedBySessionId.entries()] .filter(([, owned]) => owned) .map(([sessionId]) => sessionId), @@ -889,12 +801,14 @@ export async function parseOpenCodeUsageDatabase( export async function scanOpenCodeUsageDatabases( worktrees: OpenCodeUsageWorktreeRef[], - previousProcessedDatabases: OpenCodeUsagePersistedDatabase[] + previousProcessedDatabases: OpenCodeUsagePersistedDatabase[], + options: { budget?: UsageHistoryScanBudget } = {} ): Promise<{ processedDatabases: OpenCodeUsagePersistedDatabase[] sessions: OpenCodeUsageSession[] dailyAggregates: OpenCodeUsageDailyAggregate[] }> { + const budget = options.budget ?? new UsageHistoryScanBudget() const dbPaths = await listOpenCodeDatabases() const previousByPath = new Map( previousProcessedDatabases.map((database) => [database.path, database]) @@ -965,7 +879,12 @@ export async function scanOpenCodeUsageDatabases( const sessionOwnerById = new Map() for (const dbPath of [...reusedByPath.keys()].sort(compareOpenCodeClaimPriority)) { const previous = reusedByPath.get(dbPath) + for (const session of previous?.sessions ?? []) { + budget.claimRecords(session.eventCount) + } + claimOpenCodeUsageProjection(budget, previous?.sessions ?? [], previous?.dailyAggregates ?? []) for (const sessionId of previous?.ownedSessionIds ?? []) { + budget.claimOwnershipKey(sessionId) if (!sessionOwnerById.has(sessionId)) { sessionOwnerById.set(sessionId, dbPath) } @@ -976,6 +895,7 @@ export async function scanOpenCodeUsageDatabases( const orderedPathsToParse = [...pathsToParse].sort(compareOpenCodeClaimPriority) for (const [index, dbPath] of orderedPathsToParse.entries()) { const processed = await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths, { + budget, claimSession: (sessionId) => { const owner = sessionOwnerById.get(sessionId) if (owner !== undefined && owner !== dbPath) { diff --git a/src/main/opencode-usage/sqlite-usage-row-stream.test.ts b/src/main/opencode-usage/sqlite-usage-row-stream.test.ts new file mode 100644 index 00000000000..9244d6728fa --- /dev/null +++ b/src/main/opencode-usage/sqlite-usage-row-stream.test.ts @@ -0,0 +1,118 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../sqlite/sync-database' +import { UsageHistoryScanBudget, UsageHistoryScanCapacityError } from '../usage-history-scan-budget' +import { parseOpenCodeUsageDatabase } from './scanner' +import { + iterateOpenCodeUsageRows, + OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES +} from './sqlite-usage-row-stream' + +const tempDirs: string[] = [] + +function createDatabase(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-retention-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + const db = new Database(path) + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + cost REAL, + tokens_input INTEGER, + tokens_output INTEGER, + tokens_reasoning INTEGER, + tokens_cache_read INTEGER, + time_created INTEGER, + time_updated INTEGER + ); + `) + return { db, path } +} + +function insertSession(db: Database.Database, id: string, title = '', inputTokens = 1): void { + db.prepare( + `INSERT INTO session ( + id, directory, title, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, + time_created, time_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run(id, null, title, 0, inputTokens, 0, 0, 0, 1_777_777_700_000, 1_777_777_800_000) +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('OpenCode SQLite usage row retention', () => { + it('preserves an exact-boundary row', () => { + const { db } = createDatabase() + const title = 't'.repeat(OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES - 2) + insertSession(db, 's', title) + + const rows = [...iterateOpenCodeUsageRows(db)] + + expect(rows).toHaveLength(1) + expect(rows[0]?.id).toBe('s') + expect(rows[0]?.title).toBe(title) + db.close() + }) + + it('rejects a row one byte above the retained-text limit', () => { + const { db } = createDatabase() + insertSession(db, 's', 't'.repeat(OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES - 1)) + + expect(() => [...iterateOpenCodeUsageRows(db)]).toThrowError( + new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES) + ) + db.close() + }) + + it('rejects oversized legacy JSON before asking SQLite to parse it', () => { + const { db } = createDatabase() + insertSession(db, 's', '', 0) + db.exec(` + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + `) + const data = `{"role":"assistant","padding":"${'x'.repeat( + OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES + )}"}` + db.prepare( + 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run('m', 's', 1, 1, data) + + expect(() => [...iterateOpenCodeUsageRows(db)]).toThrowError( + new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES) + ) + db.close() + }) + + it('shares the history record budget across streamed rows', async () => { + const { db, path } = createDatabase() + insertSession(db, 'session-1') + insertSession(db, 'session-2') + db.close() + const budget = new UsageHistoryScanBudget({ + records: 1, + ownershipKeys: 4, + retainedBytes: 16 * 1024 * 1024 + }) + + await expect(parseOpenCodeUsageDatabase(path, [], { budget })).rejects.toMatchObject({ + resource: 'records', + limit: 1 + }) + }) +}) diff --git a/src/main/opencode-usage/sqlite-usage-row-stream.ts b/src/main/opencode-usage/sqlite-usage-row-stream.ts new file mode 100644 index 00000000000..9c7cbca46fd --- /dev/null +++ b/src/main/opencode-usage/sqlite-usage-row-stream.ts @@ -0,0 +1,238 @@ +import type Database from '../sqlite/sync-database' +import { UsageHistoryScanCapacityError } from '../usage-history-scan-budget' +import { columnExists, tableExists } from './schema-helpers' + +export const OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES = 4 * 1024 * 1024 + +export type OpenCodeUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + data: string + directory: string | null + title: string | null + worktree: string | null + session_model: string | null +} + +type OpenCodeSessionUsageRow = Omit & { + cost: number + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number +} + +function retainedTextBytesSql(expressions: readonly string[]): string { + return expressions + .map((expression) => `length(CAST(COALESCE(${expression}, '') AS BLOB))`) + .join(' + ') +} + +function assertNoOversizedRows( + db: Database.Database, + fromSql: string, + whereSql: string, + rowBytesSql: string +): void { + const oversized = db + .prepare( + `SELECT 1 + ${fromSql} + WHERE ${whereSql} + AND (${rowBytesSql}) > ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES} + LIMIT 1` + ) + .get() + if (oversized) { + throw new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES) + } +} + +function assertNoOversizedJsonCandidates( + db: Database.Database, + table: 'message' | 'session_message', + candidateWhereSql: string +): void { + const oversized = db + .prepare( + `SELECT 1 + FROM ${table} + WHERE ${candidateWhereSql} + AND length(CAST(COALESCE(data, '') AS BLOB)) > ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES} + LIMIT 1` + ) + .get() + if (oversized) { + throw new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES) + } +} + +function boundedJsonPredicate(dataExpression: string, predicate: string): string { + return `CASE + WHEN length(CAST(COALESCE(${dataExpression}, '') AS BLOB)) <= ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES} + THEN (${predicate}) + ELSE 0 + END` +} + +function getProjectJoin(db: Database.Database): string { + return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') + ? 'LEFT JOIN project p ON p.id = s.project_id' + : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' +} + +function getSessionModelSelect(db: Database.Database): string { + return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' +} + +function getAssistantSessionMessageCount(db: Database.Database): number { + if (!tableExists(db, 'session_message')) { + return 0 + } + const hasType = columnExists(db, 'session_message', 'type') + assertNoOversizedJsonCandidates(db, 'session_message', hasType ? "type = 'assistant'" : '1 = 1') + const jsonPredicate = boundedJsonPredicate( + 'data', + "json_extract(data, '$.tokens.input') IS NOT NULL" + ) + const assistantPredicate = hasType ? `type = 'assistant' AND ${jsonPredicate}` : jsonPredicate + const row = db + .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function canReadSessionUsageRows(db: Database.Database): boolean { + if (!tableExists(db, 'session')) { + return false + } + return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( + (columnName) => columnExists(db, 'session', columnName) + ) +} + +function getSessionUsageRowCount(db: Database.Database): number { + if (!canReadSessionUsageRows(db)) { + return 0 + } + const row = db + .prepare( + `SELECT COUNT(*) AS count + FROM session + WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` + ) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function* iterateSessionUsageRows(db: Database.Database): Iterable { + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + const fromSql = `FROM session s ${projectJoin}` + const whereSql = 's.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0' + const rowBytesSql = retainedTextBytesSql([ + 's.id', + 's.id', + 's.directory', + 's.title', + 'p.worktree', + columnExists(db, 'session', 'model') ? 's.model' : "''" + ]) + assertNoOversizedRows(db, fromSql, whereSql, rowBytesSql) + + const rows = db + .prepare( + `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, + s.directory, s.title, p.worktree, ${sessionModelSelect}, + s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read + ${fromSql} + WHERE ${whereSql} + AND (${rowBytesSql}) <= ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES} + ORDER BY s.time_created, s.id` + ) + .iterate() as Iterable + + for (const row of rows) { + yield { + id: row.id, + session_id: row.session_id, + time_created: row.time_created, + time_updated: row.time_updated, + directory: row.directory, + title: row.title, + worktree: row.worktree, + session_model: row.session_model, + data: JSON.stringify({ + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + total: row.tokens_input + row.tokens_output + row.tokens_reasoning, + cache: { read: row.tokens_cache_read, write: 0 } + } + }) + } + } +} + +function iterateMessageUsageRows( + db: Database.Database, + table: 'message' | 'session_message', + assistantPredicate: string +): Iterable { + const alias = table === 'message' ? 'm' : 'sm' + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + const fromSql = `FROM ${table} ${alias} JOIN session s ON s.id = ${alias}.session_id ${projectJoin}` + const rowBytesSql = retainedTextBytesSql([ + `${alias}.id`, + `${alias}.session_id`, + `${alias}.data`, + 's.directory', + 's.title', + 'p.worktree', + columnExists(db, 'session', 'model') ? 's.model' : "''" + ]) + assertNoOversizedRows(db, fromSql, assistantPredicate, rowBytesSql) + + return db + .prepare( + `SELECT ${alias}.id, ${alias}.session_id, ${alias}.time_created, + ${alias}.time_updated, ${alias}.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + ${fromSql} + WHERE ${assistantPredicate} + AND (${rowBytesSql}) <= ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES} + ORDER BY ${alias}.time_created, ${alias}.id` + ) + .iterate() as Iterable +} + +export function iterateOpenCodeUsageRows(db: Database.Database): Iterable { + if (!tableExists(db, 'session')) { + return [] + } + if (getSessionUsageRowCount(db) > 0) { + return iterateSessionUsageRows(db) + } + + if (getAssistantSessionMessageCount(db) > 0) { + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "sm.type = 'assistant'" + : boundedJsonPredicate('sm.data', "json_extract(sm.data, '$.tokens.input') IS NOT NULL") + return iterateMessageUsageRows(db, 'session_message', assistantPredicate) + } + + if (!tableExists(db, 'message')) { + return [] + } + assertNoOversizedJsonCandidates(db, 'message', '1 = 1') + return iterateMessageUsageRows( + db, + 'message', + boundedJsonPredicate('m.data', "json_extract(m.data, '$.role') = 'assistant'") + ) +} diff --git a/src/main/opencode/config-overlay-manifest.test.ts b/src/main/opencode/config-overlay-manifest.test.ts new file mode 100644 index 00000000000..0818a98cf94 --- /dev/null +++ b/src/main/opencode/config-overlay-manifest.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES, + CONFIG_OVERLAY_MAX_SOURCE_ENTRIES, + ConfigOverlayCapacityError +} from '../pty/config-overlay-mirroring' +import { _configOverlayManifestInternals } from './config-overlay-manifest' + +const { parseOverlayManifest } = _configOverlayManifestInternals + +describe('OpenCode overlay manifest bounds', () => { + it('accepts the exact entry limit and rejects one more retained entry', () => { + const exact = parseOverlayManifest( + JSON.stringify({ + topLevelEntries: Array.from({ length: CONFIG_OVERLAY_MAX_SOURCE_ENTRIES }, () => 'same'), + pluginEntries: [] + }) + ) + expect(exact.topLevelEntries).toHaveLength(CONFIG_OVERLAY_MAX_SOURCE_ENTRIES) + + expect(() => + parseOverlayManifest( + JSON.stringify({ + topLevelEntries: Array.from( + { length: CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + 1 }, + () => 'same' + ), + pluginEntries: [] + }) + ) + ).toThrowError( + new ConfigOverlayCapacityError( + 'entries', + CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + 1, + CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + ) + ) + }) + + it('accepts the exact encoded-name budget and rejects the next name', () => { + const name = 'a'.repeat(4_094) + const names = Array.from({ length: CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES / 4_096 }, () => name) + expect( + parseOverlayManifest(JSON.stringify({ topLevelEntries: names })).topLevelEntries + ).toHaveLength(names.length) + + expect(() => + parseOverlayManifest(JSON.stringify({ topLevelEntries: [...names, 'a'] })) + ).toThrowError( + new ConfigOverlayCapacityError( + 'retained-name-bytes', + CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES + 3, + CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES + ) + ) + }) + + it('never authorizes cleanup of reserved overlay-owned paths', () => { + expect( + parseOverlayManifest( + JSON.stringify({ + topLevelEntries: ['plugins', '.orca-opencode-overlay-manifest.json', 'auth.json'], + pluginEntries: ['orca-opencode-status.js', 'user-plugin.js'] + }) + ) + ).toEqual({ + topLevelEntries: ['auth.json'], + pluginEntries: ['user-plugin.js'] + }) + }) +}) diff --git a/src/main/opencode/config-overlay-manifest.ts b/src/main/opencode/config-overlay-manifest.ts new file mode 100644 index 00000000000..7a520f05347 --- /dev/null +++ b/src/main/opencode/config-overlay-manifest.ts @@ -0,0 +1,164 @@ +import { unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { stringifyJsonWithinByteLimit } from '../../shared/memory-safety/node-bounded-json-stringify' +import { + NodeFileReadTooLargeError, + readNodeFileSyncWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' +import { + CONFIG_OVERLAY_MAX_SOURCE_ENTRIES, + ConfigOverlayCapacityError, + ConfigOverlayEntryBudget, + applyConfigOverlayPlan, + createConfigOverlayPlan, + type AppliedConfigOverlayEntries +} from '../pty/config-overlay-mirroring' +import { safeRemoveTree } from '../pty/overlay-mirror' + +export const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' +export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' +export const OPENCODE_OVERLAY_MANIFEST_MAX_BYTES = 2 * 1_024 * 1_024 + +type OpenCodeOverlayManifest = { + topLevelEntries: string[] + pluginEntries: string[] +} + +const RESERVED_TOP_LEVEL_ENTRIES = new Set([OPENCODE_OVERLAY_MANIFEST_FILE]) + +function emptyManifest(): OpenCodeOverlayManifest { + return { topLevelEntries: [], pluginEntries: [] } +} + +function parseManifestEntryNames( + value: unknown, + kind: 'top-level' | 'plugin', + budget: ConfigOverlayEntryBudget +): string[] { + if (!Array.isArray(value)) { + return [] + } + + const names: string[] = [] + for (const candidate of value) { + if (typeof candidate !== 'string') { + continue + } + budget.reserve(candidate) + if ( + (kind === 'top-level' && + (candidate === 'plugins' || candidate === OPENCODE_OVERLAY_MANIFEST_FILE)) || + (kind === 'plugin' && candidate === ORCA_OPENCODE_PLUGIN_FILE) + ) { + continue + } + names.push(candidate) + } + return names +} + +function parseOverlayManifest(contents: string): OpenCodeOverlayManifest { + const parsed = JSON.parse(contents) as Partial + const topLevelCount = Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries.length : 0 + const pluginCount = Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries.length : 0 + if (topLevelCount + pluginCount > CONFIG_OVERLAY_MAX_SOURCE_ENTRIES) { + throw new ConfigOverlayCapacityError( + 'entries', + topLevelCount + pluginCount, + CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + ) + } + + const budget = new ConfigOverlayEntryBudget() + return { + topLevelEntries: parseManifestEntryNames(parsed.topLevelEntries, 'top-level', budget), + pluginEntries: parseManifestEntryNames(parsed.pluginEntries, 'plugin', budget) + } +} + +function readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { + try { + const contents = readNodeFileSyncWithinLimit( + join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), + OPENCODE_OVERLAY_MANIFEST_MAX_BYTES + ).buffer.toString('utf8') + return parseOverlayManifest(contents) + } catch (error) { + if (error instanceof ConfigOverlayCapacityError || error instanceof NodeFileReadTooLargeError) { + throw error + } + return emptyManifest() + } +} + +function removePathBeforeWrite(path: string): void { + try { + unlinkSync(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } +} + +function writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { + const manifestPath = join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE) + const { serialized } = stringifyJsonWithinByteLimit( + manifest, + OPENCODE_OVERLAY_MANIFEST_MAX_BYTES - 1 + ) + removePathBeforeWrite(manifestPath) + writeFileSync(manifestPath, `${serialized}\n`) +} + +function clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { + for (const entryName of manifest.topLevelEntries) { + if (!safeRemoveTree(join(overlayDir, entryName))) { + throw new Error('Unable to clear an OpenCode config overlay entry') + } + } + + const overlayPluginsDir = join(overlayDir, 'plugins') + for (const entryName of manifest.pluginEntries) { + if (!safeRemoveTree(join(overlayPluginsDir, entryName))) { + throw new Error('Unable to clear an OpenCode plugin overlay entry') + } + } +} + +function toManifest(applied: AppliedConfigOverlayEntries): OpenCodeOverlayManifest { + return { + topLevelEntries: applied.topLevelEntryNames, + pluginEntries: applied.pluginEntryNames + } +} + +export function mirrorOpenCodeConfigWithManifest(sourceDir: string, overlayDir: string): void { + const plan = createConfigOverlayPlan(sourceDir, { + reservedPluginFile: ORCA_OPENCODE_PLUGIN_FILE, + reservedTopLevelEntryNames: RESERVED_TOP_LEVEL_ENTRIES + }) + const previousManifest = readOverlayManifest(overlayDir) + clearManifestEntries(overlayDir, previousManifest) + + const applied: AppliedConfigOverlayEntries = { + topLevelEntryNames: [], + pluginEntryNames: [] + } + try { + applyConfigOverlayPlan(plan, overlayDir, applied) + writeOverlayManifest(overlayDir, toManifest(applied)) + } catch (error) { + try { + clearManifestEntries(overlayDir, toManifest(applied)) + } catch { + // Preserve the original mirror/write failure; a later spawn can retry cleanup. + } + throw error + } +} + +export const _configOverlayManifestInternals = { + parseOverlayManifest, + writeOverlayManifest +} diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index f3956519ee7..df9fe7b80e7 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -8,10 +8,12 @@ import { readdirSync, rmSync, symlinkSync, + truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { GENERATED_NODE_MANAGED_FILE_MAX_BYTES } from '../generated-node-bounded-file-reader' const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>() @@ -24,6 +26,10 @@ vi.mock('electron', () => ({ })) import { OpenCodeHookService, _internals } from './hook-service' +import { + OPENCODE_OVERLAY_MANIFEST_FILE, + OPENCODE_OVERLAY_MANIFEST_MAX_BYTES +} from './config-overlay-manifest' const { isUsableId, toSafeDirName } = _internals @@ -65,6 +71,11 @@ describe('OpenCode hook plugin source', () => { expect(source).toContain('const coords = resolveHookCoords();') expect(source).toContain('`http://127.0.0.1:${coords.port}/hook/opencode`') expect(source).toContain('"X-Orca-Agent-Hook-Token": coords.token') + expect(source).toContain( + `function readOrcaManagedFileWithinLimit(fs, path, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES})` + ) + expect(source).toContain('readOrcaManagedFileWithinLimit(fs, path)') + expect(source).not.toContain('fs.readFileSync') }) it('caches the parsed endpoint file on mtime+size+inode to skip re-reads per post', () => { @@ -355,6 +366,81 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = expectUserConfigIntact() }) + it('does not mirror or overwrite a user file named like the internal manifest', () => { + const userManifest = join(userConfigDir, OPENCODE_OVERLAY_MANIFEST_FILE) + writeFileSync(userManifest, 'USER MANIFEST SENTINEL') + + const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir) + const overlayManifest = join(env.OPENCODE_CONFIG_DIR!, OPENCODE_OVERLAY_MANIFEST_FILE) + + expect(readFileSync(userManifest, 'utf8')).toBe('USER MANIFEST SENTINEL') + expect(lstatSync(overlayManifest).isSymbolicLink()).toBe(false) + expect(JSON.parse(readFileSync(overlayManifest, 'utf8'))).toMatchObject({ + topLevelEntries: expect.arrayContaining(['auth.json', 'opencode.json']) + }) + }) + + it('falls back before mutation when a retained manifest exceeds its byte limit', () => { + const service = new OpenCodeHookService() + const first = service.buildPtyEnv(ptyId, userConfigDir) + const overlayDir = first.OPENCODE_CONFIG_DIR! + const overlayManifest = join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE) + truncateSync(overlayManifest, OPENCODE_OVERLAY_MANIFEST_MAX_BYTES + 1) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + expect(service.buildPtyEnv(ptyId, userConfigDir)).toEqual({ + OPENCODE_CONFIG_DIR: userConfigDir + }) + expect(readFileSync(join(overlayDir, 'auth.json'), 'utf8')).toBe('user-auth-token') + expect(warnSpy).toHaveBeenCalledWith( + '[opencode-hooks] config overlay exceeded its memory limit; using the original OPENCODE_CONFIG_DIR without Orca status integration' + ) + expectUserConfigIntact() + }) + + it('ignores forged manifest traversal entries during cleanup', () => { + const overlayDir = join( + userDataDir, + 'opencode-config-overlays', + toSafeDirName(`source:${userConfigDir}`) + ) + mkdirSync(overlayDir, { recursive: true }) + const outsideMarker = join(userDataDir, 'outside-manifest-marker') + writeFileSync(outsideMarker, 'keep') + writeFileSync( + join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: ['../outside-manifest-marker'] }) + ) + + const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir) + + expect(env.OPENCODE_CONFIG_DIR).toBe(overlayDir) + expect(readFileSync(outsideMarker, 'utf8')).toBe('keep') + }) + + it.skipIf(process.platform === 'win32')( + 'replaces a stale manifest symlink without writing through to the user file', + () => { + const userManifest = join(userConfigDir, OPENCODE_OVERLAY_MANIFEST_FILE) + writeFileSync(userManifest, 'USER MANIFEST SENTINEL') + const overlayDir = join( + userDataDir, + 'opencode-config-overlays', + toSafeDirName(`source:${userConfigDir}`) + ) + mkdirSync(overlayDir, { recursive: true }) + symlinkSync(userManifest, join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'file') + + const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir) + + expect(env.OPENCODE_CONFIG_DIR).toBe(overlayDir) + expect(readFileSync(userManifest, 'utf8')).toBe('USER MANIFEST SENTINEL') + expect(lstatSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE)).isSymbolicLink()).toBe( + false + ) + } + ) + it.skipIf(process.platform === 'win32')( 'does not write through a symlinked plugins/ directory into the user filesystem', () => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index c2baa488dc9..0d38ed943f5 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,29 +1,19 @@ /* eslint-disable max-lines -- Why: holds an inline JS plugin source emitted as one file; splitting across TS modules would scatter tightly coupled string-template logic. */ import { app } from 'electron' import { join } from 'node:path' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - statSync, - unlinkSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { NodeFileReadTooLargeError } from '../../shared/memory-safety/node-bounded-file-reader' +import { getGeneratedNodeBoundedFileReaderSourceLines } from '../generated-node-bounded-file-reader' +import { ConfigOverlayCapacityError } from '../pty/config-overlay-mirroring' +import { + ORCA_OPENCODE_PLUGIN_FILE, + mirrorOpenCodeConfigWithManifest +} from './config-overlay-manifest' -const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' const OPENCODE_SHARED_CONFIG_DIR = 'shared' -const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' - -type OpenCodeOverlayManifest = { - topLevelEntries: string[] - pluginEntries: string[] -} // Why: bounds-check only — the id is a daemon sessionId with path separators, hashed downstream to a filesystem-safe name (an old regex rejecting "/"/":" broke every such id, #1148); 1024 just caps pathological hash input. function isUsableId(id: string): boolean { @@ -48,12 +38,13 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { "// OpenCode's Node process (not Orca's) and has no access to server.ts's", '// equivalent warnedVersions / warnedEnvs Sets.', 'let warnedBadEndpoint = false;', + ...getGeneratedNodeBoundedFileReaderSourceLines(), '', '// Why: message.part.updated can fire many times per second during a', '// streaming assistant reply, and each post() calls resolveHookCoords()', '// which reads the endpoint file. The file only changes on Orca restart', '// (rare), so a stat+mtime check is substantially cheaper than a full', - '// readFileSync+parse on every streamed part. On stat error we fall', + '// bounded read+parse on every streamed part. On stat error we fall', '// through to parse so the fail-open behavior is preserved.', 'let cachedEndpointKey = "";', 'let cachedEndpointValues = null;', @@ -74,7 +65,7 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', ' return cachedEndpointValues;', ' }', - ' const contents = fs.readFileSync(path, "utf8");', + ' const contents = readOrcaManagedFileWithinLimit(fs, path);', ' const out = {};', ' for (const line of contents.split(/\\r?\\n/)) {', ' // Why: Windows endpoint.cmd uses `set KEY=VALUE`; Unix endpoint.env', @@ -91,7 +82,7 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' return out;', ' } catch (ioErr) {', ' // Why: any stat or read failure (file yanked mid-read, permission', - ' // race, unlink between stat and readFileSync) must invalidate the', + ' // race, unlink between stat and bounded read) must invalidate the', ' // cache so a transient failure does not lock in a stale parse for', ' // the remaining process lifetime; rethrow to the outer catch.', ' cachedEndpointKey = "";', @@ -377,6 +368,8 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { // Why: installs the plugin into OPENCODE_CONFIG_DIR so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini (the old loopback-IPC path never reached agentStatusByPaneKey). export class OpenCodeHookService { + private warnedOverlayCapacity = false + clearPty(_ptyId: string): void { // Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows. } @@ -407,7 +400,16 @@ export class OpenCodeHookService { mkdirSync(overlayDir, { recursive: true }) this.mirrorUserConfig(existingConfigDir, overlayDir) this.writePluginIntoOverlay(overlayDir) - } catch { + } catch (error) { + if ( + !this.warnedOverlayCapacity && + (error instanceof ConfigOverlayCapacityError || error instanceof NodeFileReadTooLargeError) + ) { + this.warnedOverlayCapacity = true + console.warn( + '[opencode-hooks] config overlay exceeded its memory limit; using the original OPENCODE_CONFIG_DIR without Orca status integration' + ) + } // Why: best-effort — symlink creation needs Windows developer mode (else EPERM) and userData may be read-only; preserve the user's config over dropping their auth/models/keymap. return { OPENCODE_CONFIG_DIR: existingConfigDir } } @@ -427,90 +429,8 @@ export class OpenCodeHookService { return join(app.getPath('userData'), OPENCODE_LEGACY_HOOKS_DIR, OPENCODE_SHARED_CONFIG_DIR) } - private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { - try { - const parsed = JSON.parse( - readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') - ) as Partial - return { - topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [], - pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : [] - } - } catch { - return { topLevelEntries: [], pluginEntries: [] } - } - } - - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - writeFileSync( - join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), - `${JSON.stringify(manifest, null, 2)}\n` - ) - } - - private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - for (const entryName of manifest.topLevelEntries) { - safeRemoveTree(join(overlayDir, entryName)) - } - - const overlayPluginsDir = join(overlayDir, 'plugins') - for (const entryName of manifest.pluginEntries) { - if (entryName === ORCA_OPENCODE_PLUGIN_FILE) { - continue - } - safeRemoveTree(join(overlayPluginsDir, entryName)) - } - } - - // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { - const previousManifest = this.readOverlayManifest(overlayDir) - // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. - this.clearManifestEntries(overlayDir, previousManifest) - - const nextManifest: OpenCodeOverlayManifest = { topLevelEntries: [], pluginEntries: [] } - - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - - if (entry.name === 'plugins') { - // Why: check isSymbolicLink before isDirectory — a Windows junction reports both, and the symlink branch must win. - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - // Why: broken/inaccessible symlink — mirror the dangling link verbatim instead of resolving through it. - isLinkPointingToDir = false - } - } - - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - // Why: resolve a symlinked plugins/ to its real target so /plugins stays a real dir and writePluginIntoOverlay can't write through the user's link. - const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - const overlayPluginsDir = join(overlayDir, 'plugins') - mkdirSync(overlayPluginsDir, { recursive: true }) - for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. - if (pluginEntry.name === ORCA_OPENCODE_PLUGIN_FILE) { - continue - } - mirrorEntry( - join(resolvedSource, pluginEntry.name), - join(overlayPluginsDir, pluginEntry.name) - ) - nextManifest.pluginEntries.push(pluginEntry.name) - } - continue - } - } - - mirrorEntry(sourcePath, join(overlayDir, entry.name)) - nextManifest.topLevelEntries.push(entry.name) - } - - this.writeOverlayManifest(overlayDir, nextManifest) + mirrorOpenCodeConfigWithManifest(sourceDir, overlayDir) } // Why: pre-write unlink guards against POSIX writeFileSync writing through a mirrored symlink and clobbering a same-named user plugin. @@ -520,8 +440,10 @@ export class OpenCodeHookService { const pluginPath = join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE) try { unlinkSync(pluginPath) - } catch { - // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } } writeFileSync(pluginPath, getOpenCodePluginSource()) } @@ -531,7 +453,7 @@ export class OpenCodeHookService { const pluginsDir = join(configDir, 'plugins') try { mkdirSync(pluginsDir, { recursive: true }) - writeFileSync(join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE), getOpenCodePluginSource()) + this.writePluginIntoOverlay(configDir) } catch { // Why: userData can be locked on Windows (EPERM/EBUSY); plugin is non-critical, so spawn without it. return null diff --git a/src/main/opencode/opencode-database-files.ts b/src/main/opencode/opencode-database-files.ts new file mode 100644 index 00000000000..95e3abb9d20 --- /dev/null +++ b/src/main/opencode/opencode-database-files.ts @@ -0,0 +1,40 @@ +import { opendir } from 'node:fs/promises' +import { join } from 'node:path' + +export const OPENCODE_DATABASE_FILE_LIMIT = 256 + +type OpenCodeDatabaseDirectoryEntry = { + name: string + isFile(): boolean +} + +export type OpenCodeDatabaseFiles = { + paths: string[] + truncated: boolean +} + +export async function listOpenCodeDatabaseFiles(dataDir: string): Promise { + try { + return collectOpenCodeDatabaseFiles(dataDir, await opendir(dataDir)) + } catch { + return { paths: [], truncated: false } + } +} + +export async function collectOpenCodeDatabaseFiles( + dataDir: string, + directory: AsyncIterable, + maxFiles = OPENCODE_DATABASE_FILE_LIMIT +): Promise { + const paths: string[] = [] + for await (const entry of directory) { + if (!entry.isFile() || !/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) { + continue + } + if (paths.length >= maxFiles) { + return { paths: paths.sort(), truncated: true } + } + paths.push(join(dataDir, entry.name)) + } + return { paths: paths.sort(), truncated: false } +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 8d41c81a3a8..1894a42835a 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2872,6 +2872,151 @@ describe('Store', () => { expect(store.getSettings().floatingTerminalDefaultedForAllUsers).toBe(true) }) + it('migrates the legacy OSC 52 clipboard disabled default to enabled', async () => { + // Why this migration exists: the old off default was persisted for every + // profile, so without the one-shot flip #10567 would only fix new installs. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalAllowOsc52Clipboard: false }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().terminalAllowOsc52Clipboard).toBe(true) + expect(store.getSettings().terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + + it('persists the OSC 52 clipboard migration stamp back to disk', async () => { + // Why round-trip a store first: on a bare legacy profile ~30 other migrations also + // set loadNeedsSave, so the save happens regardless and this migration's own dirty + // flag goes untested. Re-loading a profile the new build already wrote leaves OSC 52 + // as the only unmigrated key — which is exactly the upgrade case that matters. + // Why no flush(): flush() writes unconditionally, so only the debounced load-path + // save proves this migration marked the state dirty by itself. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const migrated = await createStore() + migrated.flush() + + const settled = readDataFile() as { settings: Record } + settled.settings.terminalAllowOsc52Clipboard = false + delete settled.settings.terminalAllowOsc52ClipboardDefaultedOnForAllUsers + writeDataFile(settled) + + vi.useFakeTimers() + try { + const store = await createStore() + // Why over-advance: the debounce is exactly 1000ms, so an exact-fit advance turns a + // future debounce raise into a confusing no-write instead of a loud failure. + vi.advanceTimersByTime(5000) + await store.waitForPendingWrite() + } finally { + vi.useRealTimers() + } + + const persisted = readDataFile() as { + settings?: { + terminalAllowOsc52Clipboard?: boolean + terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean + } + } + + expect(persisted.settings?.terminalAllowOsc52Clipboard).toBe(true) + expect(persisted.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + + it('preserves a post-migration OSC 52 clipboard opt-out', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().terminalAllowOsc52Clipboard).toBe(false) + expect(store.getSettings().terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + + it('arms the one-shot notice when the OSC 52 flip overrides a persisted off', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalAllowOsc52Clipboard: false }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().osc52ClipboardDefaultOnNoticePending).toBe(true) + }) + + it('leaves the OSC 52 notice disarmed for a profile with no persisted value', async () => { + // Why: the notice explains an overridden choice; a fresh profile made no choice. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().osc52ClipboardDefaultOnNoticePending).toBe(false) + }) + + it('keeps the OSC 52 notice armed on disk until the renderer clears it', async () => { + // Why the disk assertion: the flip happens during load, before any window exists, and + // once the settings stamp lands the arming predicate is false forever — so the on-disk + // ui flag is the only thing that survives a crash before the toast renders. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalAllowOsc52Clipboard: false }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const armed = await createStore() + armed.flush() + const armedOnDisk = readDataFile() as { + ui?: { osc52ClipboardDefaultOnNoticePending?: boolean } + } + expect(armedOnDisk.ui?.osc52ClipboardDefaultOnNoticePending).toBe(true) + + const reloaded = await createStore() + expect(reloaded.getUI().osc52ClipboardDefaultOnNoticePending).toBe(true) + + reloaded.updateUI({ osc52ClipboardDefaultOnNoticePending: false }) + reloaded.flush() + const cleared = await createStore() + // Why re-check after the stamp: a cleared notice must not be resurrected by a later load. + expect(cleared.getUI().osc52ClipboardDefaultOnNoticePending).toBe(false) + }) + it('migrates the legacy Linux primary-selection default to enabled', async () => { await withPlatform('linux', async () => { writeDataFile({ @@ -4386,6 +4531,29 @@ describe('Store', () => { expect(reloaded.getRepo('r1')!.upstream).toBeNull() }) + it('updateRepo persists the resolved no-usable-remote identity marker', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const updated = store.updateRepo('r1', { + gitRemoteIdentity: { + canonicalKey: 'gitlab.example.com/team/orca', + remoteName: 'origin', + remoteUrl: 'git@gitlab.example.com:team/orca.git' + } + }) + expect(updated!.gitRemoteIdentity).toEqual({ + canonicalKey: 'gitlab.example.com/team/orca', + remoteName: 'origin', + remoteUrl: 'git@gitlab.example.com:team/orca.git' + }) + + store.updateRepo('r1', { gitRemoteIdentity: null }) + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')!.gitRemoteIdentity).toBeNull() + }) + it('getRepo does not expose invalid persisted repo upstream metadata', async () => { const store = await createStore() store.addRepo(makeRepo({ upstream: { owner: '', repo: 42 } as never })) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 64b58d28300..b26c68a9af5 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -223,6 +223,10 @@ import { normalizeTuiAgentEnvRecord } from '../shared/tui-agent-launch-defaults' import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../shared/osc52-clipboard-settings' import { normalizeTerminalLineHeight } from '../shared/terminal-line-height-settings' import { normalizeUiLanguage } from '../shared/ui-language' import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom' @@ -1315,7 +1319,12 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined { return owner && repo ? { owner, repo } : undefined } -function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | undefined { +function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | null | undefined { + // Why: `null` is a resolved "no usable remote" marker; dropping it would make + // a settled repo indistinguishable from one whose identity probe is pending. + if (value === null) { + return null + } if (!value || typeof value !== 'object') { return undefined } @@ -1374,7 +1383,7 @@ function sanitizeRepoUpdatesForPersistence< sanitized.repoIcon = repoIcon } } - // Why: `null` is a valid "not a fork" marker; only drop malformed shapes. + // Why: `null` is a valid "not a fork" / "no usable remote" marker; only drop malformed shapes. if ('upstream' in sanitized) { const upstream = sanitizeRepoUpstream(sanitized.upstream) if (upstream === undefined) { @@ -2904,6 +2913,14 @@ export class Store { const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers ? (parsed.settings?.floatingTerminalEnabled ?? true) : true + // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). + const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) + const osc52ClipboardNoticePending = + osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || + parsed.ui?.osc52ClipboardDefaultOnNoticePending === true + if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { + this.loadNeedsSave = true + } const floatingTerminalCwdMigrated = parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. @@ -3136,6 +3153,7 @@ export class Store { localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, localAccountRuntime: migratedLocalAccountRuntime, localAccountRuntimeDefaultedToAutoForAllUsers: true, + ...migratedOsc52Clipboard, floatingTerminalEnabled: migratedFloatingTerminalEnabled, floatingTerminalDefaultedForAllUsers: true, floatingTerminalCwd: migratedFloatingTerminalCwd, @@ -3320,6 +3338,9 @@ export class Store { : false, setupGuideBrowserMilestoneLegacyComplete: parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, + // Why persist rather than notify inline: the flip lands during load, before any + // window exists, and it must survive a crash before the user ever sees the notice. + osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, sortBy: migrate ? ('smart' as const) : sort, showDotfilesByWorktree: normalizeShowDotfilesByWorktree( parsed.ui?.showDotfilesByWorktree @@ -5466,6 +5487,8 @@ export class Store { statusBarUsageMode: normalizeStatusBarUsageMode(this.state.ui?.statusBarUsageMode), // Why: strict boolean coercion so a missing/legacy value reads as false (first-run notice still fires). trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, + osc52ClipboardDefaultOnNoticePending: + this.state.ui?.osc52ClipboardDefaultOnNoticePending === true, markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth), visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds( this.state.ui?.visibleWorkspaceHostIds diff --git a/src/main/pi/legacy-omp-overlay-migration-budget.ts b/src/main/pi/legacy-omp-overlay-migration-budget.ts new file mode 100644 index 00000000000..fe4d1aaf440 --- /dev/null +++ b/src/main/pi/legacy-omp-overlay-migration-budget.ts @@ -0,0 +1,98 @@ +export const LEGACY_OMP_OVERLAY_MIGRATION_MAX_ENTRIES = 100_000 +export const LEGACY_OMP_OVERLAY_MIGRATION_MAX_DEPTH = 256 +export const LEGACY_OMP_OVERLAY_MIGRATION_MAX_PATH_BYTES = 64 * 1_024 +export const LEGACY_OMP_OVERLAY_MIGRATION_MAX_RETAINED_PATH_BYTES = 16 * 1_024 * 1_024 + +export type LegacyOverlayMigrationLimits = { + maxDepth: number + maxEntries: number + maxPathBytes: number + maxRetainedPathBytes: number +} + +const DEFAULT_LIMITS: LegacyOverlayMigrationLimits = { + maxDepth: LEGACY_OMP_OVERLAY_MIGRATION_MAX_DEPTH, + maxEntries: LEGACY_OMP_OVERLAY_MIGRATION_MAX_ENTRIES, + maxPathBytes: LEGACY_OMP_OVERLAY_MIGRATION_MAX_PATH_BYTES, + maxRetainedPathBytes: LEGACY_OMP_OVERLAY_MIGRATION_MAX_RETAINED_PATH_BYTES +} + +export class LegacyOverlayMigrationCapacityError extends Error { + constructor(kind: string, observed: number, limit: number) { + super(`Legacy OMP overlay ${kind} exceeded its ${limit} limit (observed ${observed})`) + this.name = 'LegacyOverlayMigrationCapacityError' + } +} + +export class LegacyOverlayMigrationBudget { + private entries = 0 + private retainedPathBytes = 0 + readonly limits: LegacyOverlayMigrationLimits + + constructor(requested?: Partial) { + this.limits = { + maxDepth: resolveLimit(requested?.maxDepth, DEFAULT_LIMITS.maxDepth, 'maxDepth'), + maxEntries: resolveLimit(requested?.maxEntries, DEFAULT_LIMITS.maxEntries, 'maxEntries'), + maxPathBytes: resolveLimit( + requested?.maxPathBytes, + DEFAULT_LIMITS.maxPathBytes, + 'maxPathBytes' + ), + maxRetainedPathBytes: resolveLimit( + requested?.maxRetainedPathBytes, + DEFAULT_LIMITS.maxRetainedPathBytes, + 'maxRetainedPathBytes' + ) + } + } + + visit(depth: number, ...paths: string[]): void { + const nextEntries = this.entries + 1 + if (nextEntries > this.limits.maxEntries) { + throw new LegacyOverlayMigrationCapacityError('entries', nextEntries, this.limits.maxEntries) + } + if (depth > this.limits.maxDepth) { + throw new LegacyOverlayMigrationCapacityError('depth', depth, this.limits.maxDepth) + } + for (const path of paths) { + this.measurePath(path) + } + this.entries = nextEntries + } + + retainPaths(...paths: string[]): number { + const retainedBytes = paths.reduce((total, path) => total + this.measurePath(path), 0) + const nextRetainedBytes = this.retainedPathBytes + retainedBytes + if (nextRetainedBytes > this.limits.maxRetainedPathBytes) { + throw new LegacyOverlayMigrationCapacityError( + 'retained-path-bytes', + nextRetainedBytes, + this.limits.maxRetainedPathBytes + ) + } + this.retainedPathBytes = nextRetainedBytes + return retainedBytes + } + + releasePaths(retainedBytes: number): void { + this.retainedPathBytes = Math.max(0, this.retainedPathBytes - retainedBytes) + } + + private measurePath(path: string): number { + const bytes = Buffer.byteLength(path, 'utf8') + if (bytes > this.limits.maxPathBytes) { + throw new LegacyOverlayMigrationCapacityError('path-bytes', bytes, this.limits.maxPathBytes) + } + return bytes + } +} + +function resolveLimit(requested: number | undefined, maximum: number, name: string): number { + if (requested === undefined) { + return maximum + } + if (!Number.isSafeInteger(requested) || requested < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + return Math.min(requested, maximum) +} diff --git a/src/main/pi/legacy-omp-overlay-migration.ts b/src/main/pi/legacy-omp-overlay-migration.ts index 88baf78ea9f..2bee66014ca 100644 --- a/src/main/pi/legacy-omp-overlay-migration.ts +++ b/src/main/pi/legacy-omp-overlay-migration.ts @@ -1,7 +1,11 @@ -import { cpSync, lstatSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs' -import type { Dirent, Stats } from 'node:fs' +import { cpSync, lstatSync, mkdirSync, opendirSync, unlinkSync, writeFileSync } from 'node:fs' +import type { Stats } from 'node:fs' import { dirname, join } from 'node:path' import { ORCA_PI_AGENT_STATUS_EXTENSION_FILE } from './agent-status-extension-source' +import { + LegacyOverlayMigrationBudget, + type LegacyOverlayMigrationLimits +} from './legacy-omp-overlay-migration-budget' import { ORCA_PI_PREFILL_EXTENSION_FILE } from './prefill-extension-source' import { ORCA_PI_EXTENSION_FILE } from './titlebar-extension-source' import { isSafeDescendCandidate } from '../pty/overlay-mirror' @@ -19,8 +23,27 @@ const MANAGED_EXTENSION_FILES = new Set([ type DeferredSidecar = { baseTargetPath: string - entry: Dirent - nextSegments: string[] + overlayPath: string + retainedBytes: number + targetPath: string +} + +export { + LEGACY_OMP_OVERLAY_MIGRATION_MAX_DEPTH, + LEGACY_OMP_OVERLAY_MIGRATION_MAX_ENTRIES, + LEGACY_OMP_OVERLAY_MIGRATION_MAX_PATH_BYTES, + LEGACY_OMP_OVERLAY_MIGRATION_MAX_RETAINED_PATH_BYTES +} from './legacy-omp-overlay-migration-budget' +export type { LegacyOverlayMigrationLimits } from './legacy-omp-overlay-migration-budget' + +function closeDirectory(directory: ReturnType): void { + try { + directory.closeSync() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') { + throw error + } + } } function getPathStats(path: string): Stats | undefined { @@ -64,11 +87,13 @@ function shouldSkipLegacyOverlayEntry(pathSegments: string[]): boolean { function copyLegacyFile( overlayPath: string, targetPath: string, - copiedFilePaths: Set + copiedFilePaths: Set, + budget: LegacyOverlayMigrationBudget ): boolean { if (getPathStats(targetPath)) { return true } + const retainedBytes = budget.retainPaths(targetPath) try { mkdirSync(dirname(targetPath), { recursive: true }) cpSync(overlayPath, targetPath, { @@ -79,15 +104,21 @@ function copyLegacyFile( copiedFilePaths.add(targetPath) return true } catch { + budget.releasePaths(retainedBytes) return false } } -function removeCopiedFiles(paths: string[], copiedFilePaths: Set): void { +function removeCopiedFiles( + paths: string[], + copiedFilePaths: Set, + budget: LegacyOverlayMigrationBudget +): void { for (const path of paths) { if (!copiedFilePaths.delete(path)) { continue } + budget.releasePaths(Buffer.byteLength(path, 'utf8')) try { unlinkSync(path) } catch { @@ -97,46 +128,50 @@ function removeCopiedFiles(paths: string[], copiedFilePaths: Set): void } function copyDeferredSidecars( - overlayDir: string, - sourceAgentDir: string, + budget: LegacyOverlayMigrationBudget, copiedFilePaths: Set, deferredSidecars: DeferredSidecar[] ): boolean { let completed = true const copiedSidecarsByBase = new Map() - for (const { baseTargetPath, entry, nextSegments } of deferredSidecars) { - if (!copiedFilePaths.has(baseTargetPath)) { - continue - } + for (const sidecar of deferredSidecars) { + const { baseTargetPath, overlayPath, targetPath } = sidecar + try { + if (!copiedFilePaths.has(baseTargetPath)) { + continue + } - const overlayPath = join(overlayDir, entry.name) - const targetPath = join(sourceAgentDir, ...nextSegments) - const stats = getPathStats(overlayPath) - if (!stats) { - removeCopiedFiles( - [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], - copiedFilePaths - ) - completed = false - continue - } - if (stats.isSymbolicLink() || !stats.isFile()) { - continue - } - const wasCopied = copiedFilePaths.has(targetPath) - if (!copyLegacyFile(overlayPath, targetPath, copiedFilePaths)) { - removeCopiedFiles( - [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], - copiedFilePaths - ) - completed = false - continue - } - if (!wasCopied && copiedFilePaths.has(targetPath)) { - copiedSidecarsByBase.set(baseTargetPath, [ - ...(copiedSidecarsByBase.get(baseTargetPath) ?? []), - targetPath - ]) + const stats = getPathStats(overlayPath) + if (!stats) { + removeCopiedFiles( + [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], + copiedFilePaths, + budget + ) + completed = false + continue + } + if (stats.isSymbolicLink() || !stats.isFile()) { + continue + } + const wasCopied = copiedFilePaths.has(targetPath) + if (!copyLegacyFile(overlayPath, targetPath, copiedFilePaths, budget)) { + removeCopiedFiles( + [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], + copiedFilePaths, + budget + ) + completed = false + continue + } + if (!wasCopied && copiedFilePaths.has(targetPath)) { + copiedSidecarsByBase.set(baseTargetPath, [ + ...(copiedSidecarsByBase.get(baseTargetPath) ?? []), + targetPath + ]) + } + } finally { + budget.releasePaths(sidecar.retainedBytes) } } return completed @@ -145,78 +180,93 @@ function copyDeferredSidecars( function copyMissingLegacyOmpOverlayEntries( overlayDir: string, sourceAgentDir: string, - pathSegments: string[] = [], - copiedFilePaths: Set = new Set() + pathSegments: string[], + copiedFilePaths: Set, + budget: LegacyOverlayMigrationBudget ): boolean { let completed = true - let entries: Dirent[] + let directory try { - entries = readdirSync(overlayDir, { withFileTypes: true }) + directory = opendirSync(overlayDir, { bufferSize: 32 }) } catch { return false } const deferredSidecars: DeferredSidecar[] = [] - for (const entry of entries) { - const nextSegments = [...pathSegments, entry.name] - if (shouldSkipLegacyOverlayEntry(nextSegments)) { - continue - } - - const sidecarBaseName = getSqliteSidecarBaseName(entry.name) - if (sidecarBaseName) { - deferredSidecars.push({ - baseTargetPath: join(sourceAgentDir, ...pathSegments, sidecarBaseName), - entry, - nextSegments - }) - continue - } - - const overlayPath = join(overlayDir, entry.name) - const targetPath = join(sourceAgentDir, ...nextSegments) - const stats = getPathStats(overlayPath) - if (!stats) { - completed = false - continue - } - if (stats.isSymbolicLink()) { - continue - } - if (stats.isDirectory()) { - const targetStats = getPathStats(targetPath) - if (targetStats && !isSafeDescendCandidate(targetStats)) { + try { + while (true) { + const entry = directory.readSync() + if (entry === null) { + break + } + const nextSegments = [...pathSegments, entry.name] + const overlayPath = join(overlayDir, entry.name) + const targetPath = join(sourceAgentDir, ...nextSegments) + budget.visit(nextSegments.length, overlayPath, targetPath) + if (shouldSkipLegacyOverlayEntry(nextSegments)) { continue } - if (!targetStats) { - try { - mkdirSync(targetPath, { recursive: true }) - } catch { - completed = false + + const sidecarBaseName = getSqliteSidecarBaseName(entry.name) + if (sidecarBaseName) { + const baseTargetPath = join(sourceAgentDir, ...pathSegments, sidecarBaseName) + deferredSidecars.push({ + baseTargetPath, + overlayPath, + retainedBytes: budget.retainPaths(baseTargetPath, overlayPath, targetPath), + targetPath + }) + continue + } + + const stats = getPathStats(overlayPath) + if (!stats) { + completed = false + continue + } + if (stats.isSymbolicLink()) { + continue + } + if (stats.isDirectory()) { + const targetStats = getPathStats(targetPath) + if (targetStats && !isSafeDescendCandidate(targetStats)) { continue } + if (!targetStats) { + try { + mkdirSync(targetPath, { recursive: true }) + } catch { + completed = false + continue + } + } + completed = + copyMissingLegacyOmpOverlayEntries( + overlayPath, + sourceAgentDir, + nextSegments, + copiedFilePaths, + budget + ) && completed + continue } - completed = - copyMissingLegacyOmpOverlayEntries( - overlayPath, - sourceAgentDir, - nextSegments, - copiedFilePaths - ) && completed - continue + if (!stats.isFile()) { + continue + } + completed = copyLegacyFile(overlayPath, targetPath, copiedFilePaths, budget) && completed } - if (!stats.isFile()) { - continue - } - completed = copyLegacyFile(overlayPath, targetPath, copiedFilePaths) && completed + } finally { + closeDirectory(directory) } - return ( - copyDeferredSidecars(overlayDir, sourceAgentDir, copiedFilePaths, deferredSidecars) && completed - ) + return copyDeferredSidecars(budget, copiedFilePaths, deferredSidecars) && completed } -export function migrateLegacyOmpOverlayState(sourceAgentDir: string, overlayDir: string): void { +export function migrateLegacyOmpOverlayState( + sourceAgentDir: string, + overlayDir: string, + limits?: Partial +): void { // Why: temporary rescue shim for OMP builds from the legacy overlay window. // Remove after 2026-08-07 once affected users have had a full upgrade window. const overlayStats = getPathStats(overlayDir) @@ -227,16 +277,23 @@ export function migrateLegacyOmpOverlayState(sourceAgentDir: string, overlayDir: if (getPathStats(markerPath)) { return } + const budget = new LegacyOverlayMigrationBudget(limits) + const copiedFilePaths = new Set() try { mkdirSync(sourceAgentDir, { recursive: true }) // Why: some pre-managed-extension builds pointed OMP at this overlay, so // first-login auth/session files can exist only there after an update. - if (copyMissingLegacyOmpOverlayEntries(overlayDir, sourceAgentDir)) { + if ( + copyMissingLegacyOmpOverlayEntries(overlayDir, sourceAgentDir, [], copiedFilePaths, budget) + ) { // Why: legacy source overlays can be large and are intentionally kept // for recovery; mark clean migrations so future OMP spawns stay cheap. writeFileSync(markerPath, 'complete\n') } } catch (error) { + // Why: a capacity stop must not strand a copied SQLite base without a + // deferred sidecar; remove only files this attempt created, then retry later. + removeCopiedFiles([...copiedFilePaths], copiedFilePaths, budget) console.warn('[pi-titlebar-extension] failed to migrate legacy OMP overlay state:', error) } } diff --git a/src/main/pi/managed-extension-ownership.test.ts b/src/main/pi/managed-extension-ownership.test.ts new file mode 100644 index 00000000000..1f14ca77a65 --- /dev/null +++ b/src/main/pi/managed-extension-ownership.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + ORCA_MANAGED_PI_EXTENSION_MARKER, + PI_MANAGED_EXTENSION_OWNERSHIP_MAX_BYTES, + isManagedPiExtensionFile +} from './managed-extension-ownership' + +const roots: string[] = [] + +function tempFile(contents: string): string { + const root = mkdtempSync(join(tmpdir(), 'orca-managed-extension-ownership-')) + roots.push(root) + const path = join(root, 'orca-agent-status.ts') + writeFileSync(path, contents) + return path +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('managed Pi extension ownership', () => { + it('accepts a marked file at the exact byte limit', () => { + const prefix = `// ${ORCA_MANAGED_PI_EXTENSION_MARKER}\n` + const path = tempFile( + prefix + 'x'.repeat(PI_MANAGED_EXTENSION_OWNERSHIP_MAX_BYTES - prefix.length) + ) + + expect(isManagedPiExtensionFile(path)).toBe(true) + }) + + it('treats a marked file one byte over the limit as user-owned', () => { + const prefix = `// ${ORCA_MANAGED_PI_EXTENSION_MARKER}\n` + const path = tempFile( + prefix + 'x'.repeat(PI_MANAGED_EXTENSION_OWNERSHIP_MAX_BYTES - prefix.length + 1) + ) + + expect(isManagedPiExtensionFile(path)).toBe(false) + }) + + it('treats missing and unmarked files as user-owned', () => { + const path = tempFile('user extension') + + expect(isManagedPiExtensionFile(path)).toBe(false) + expect(isManagedPiExtensionFile(`${path}.missing`)).toBe(false) + }) +}) diff --git a/src/main/pi/managed-extension-ownership.ts b/src/main/pi/managed-extension-ownership.ts new file mode 100644 index 00000000000..f76a64f78b8 --- /dev/null +++ b/src/main/pi/managed-extension-ownership.ts @@ -0,0 +1,21 @@ +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' + +export const ORCA_MANAGED_PI_EXTENSION_MARKER = '@orca-managed-pi-extension' +export const PI_MANAGED_EXTENSION_OWNERSHIP_MAX_BYTES = 1_024 * 1_024 + +export function withOrcaManagedPiExtensionMarker(source: string): string { + return source.includes(ORCA_MANAGED_PI_EXTENSION_MARKER) + ? source + : `// ${ORCA_MANAGED_PI_EXTENSION_MARKER}\n${source}` +} + +export function isManagedPiExtensionFile(path: string): boolean { + try { + return readNodeFileSyncWithinLimit(path, PI_MANAGED_EXTENSION_OWNERSHIP_MAX_BYTES) + .buffer.toString('utf8') + .includes(ORCA_MANAGED_PI_EXTENSION_MARKER) + } catch { + // Unreadable or oversized files cannot safely be claimed as Orca-owned. + return false + } +} diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 37d030b5e31..4fafe42d024 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { app } from 'electron' @@ -18,6 +18,10 @@ import { safeRemoveOverlay } from '../pty/overlay-mirror' import { migrateLegacyOmpOverlayState } from './legacy-omp-overlay-migration' +import { + isManagedPiExtensionFile, + withOrcaManagedPiExtensionMarker +} from './managed-extension-ownership' import type { PiAgentKind } from '../../shared/pi-agent-kind' // Why: the Pi test suite imports `isSafeDescendCandidate` from this module's @@ -27,7 +31,6 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' export const isSafeDescendCandidate = sharedIsSafeDescendCandidate const PI_AGENT_SUBDIR = 'agent' -const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' type ManagedExtensionWriteResult = 'written' | 'skipped-user-owned' | 'failed' @@ -64,12 +67,6 @@ function toSafeOverlayDirName(ptyId: string): string { return createHash('sha256').update(ptyId).digest('hex').slice(0, 32) } -function withOrcaManagedExtensionMarker(source: string): string { - return source.includes(ORCA_MANAGED_EXTENSION_MARKER) - ? source - : `// ${ORCA_MANAGED_EXTENSION_MARKER}\n${source}` -} - export class PiTitlebarExtensionService { private getOverlayRoot(kind: PiAgentKind): string { return join(app.getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind]) @@ -98,16 +95,8 @@ export class PiTitlebarExtensionService { safeRemoveOverlay(overlayDir, this.getOverlayRoot(kind)) } - private canOverwriteManagedExtension(path: string): boolean { - try { - return readFileSync(path, 'utf8').includes(ORCA_MANAGED_EXTENSION_MARKER) - } catch { - return true - } - } - private writeManagedExtension(path: string, source: string): ManagedExtensionWriteResult { - if (existsSync(path) && !this.canOverwriteManagedExtension(path)) { + if (existsSync(path) && !isManagedPiExtensionFile(path)) { return 'skipped-user-owned' } @@ -144,14 +133,14 @@ export class PiTitlebarExtensionService { this.writeManagedExtension( join(extensionsDir, ORCA_PI_EXTENSION_FILE), - withOrcaManagedExtensionMarker(getPiTitlebarExtensionSource()) + withOrcaManagedPiExtensionMarker(getPiTitlebarExtensionSource()) ) this.writeManagedExtension( join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE), - withOrcaManagedExtensionMarker(getPiPrefillExtensionSource(kind)) + withOrcaManagedPiExtensionMarker(getPiPrefillExtensionSource(kind)) ) const statusExtensionPath = join(extensionsDir, ORCA_PI_AGENT_STATUS_EXTENSION_FILE) - const statusSource = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) + const statusSource = withOrcaManagedPiExtensionMarker(getPiAgentStatusExtensionSource(kind)) const statusResult = this.writeManagedExtension(statusExtensionPath, statusSource) return { diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index a6eeb21e5ee..5b79e499644 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import path from 'node:path' +import { LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES } from '../../shared/linux-proc-port-scan-limits' import { attributePortToWorkspace, isContainerProcess, @@ -110,6 +111,18 @@ describe('local workspace port scanner parsing', () => { expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, inode: 12345 }]) expect(usedWhitespaceFieldSplit).toBe(false) }) + + it('caps retained Linux proc listeners before process attribution', () => { + const rows = Array.from( + { length: LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES + 5 }, + (_, index) => + ` ${index}: 0100007F:${(index + 1).toString(16).padStart(4, '0')} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 ${index + 1}` + ) + + expect(parseProcNetTcp(['header', ...rows].join('\n'))).toHaveLength( + LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES + ) + }) }) describe('attributePortToWorkspace', () => { diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index 1d8f4dbf891..2e89b47b51e 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing, attribution, and normalization rules that must stay in lockstep. */ import { execFile } from 'node:child_process' -import { readFile, readdir, readlink } from 'node:fs/promises' +import { readlink } from 'node:fs/promises' import path from 'node:path' import type { WorkspacePort, @@ -10,6 +10,14 @@ import type { WorkspacePortScanResult } from '../../shared/workspace-ports' import { getProcessOutputFields } from '../../shared/process-output-field-scanner' +import { mapLinuxSocketInodesToPids } from '../../shared/linux-proc-socket-owner-scanner' +import { + createLinuxProcTextReadBudget, + LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES, + readLinuxProcNetworkTable, + readLinuxProcTextWithinBudget, + type LinuxProcTextReadBudget +} from '../../shared/linux-proc-port-scan-limits' import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher' import { WorkspacePortScanTimeoutBackoff } from './workspace-port-scan-timeout-backoff' @@ -190,6 +198,9 @@ export function parseProcNetTcp(content: string): { host: string; port: number; continue } results.push({ ...parsed, inode }) + if (results.length >= LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES) { + break + } } return results } @@ -230,15 +241,18 @@ async function scanLinuxProcPorts(): Promise { readProcNet('/proc/net/tcp'), readProcNet('/proc/net/tcp6') ]) - const sockets = [...tcp4, ...tcp6] - const inodeToPid = await mapLinuxInodesToPids(new Set(sockets.map((socket) => socket.inode))) + const sockets = [...tcp4, ...tcp6].slice(0, LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES) + const inodeToPid = await mapLinuxSocketInodesToPids( + new Set(sockets.map((socket) => socket.inode)) + ) const metadata = new Map() + const metadataBudget = createLinuxProcTextReadBudget() const rawPorts: RawListeningPort[] = [] for (const socket of sockets) { const pid = inodeToPid.get(socket.inode) if (pid != null && !metadata.has(pid)) { - metadata.set(pid, await loadLinuxProcessMetadata(pid)) + metadata.set(pid, await loadLinuxProcessMetadata(pid, metadataBudget)) } rawPorts.push({ host: socket.host, @@ -254,59 +268,17 @@ async function scanLinuxProcPorts(): Promise { async function readProcNet( filePath: string ): Promise<{ host: string; port: number; inode: number }[]> { - try { - return parseProcNetTcp(await readFile(filePath, 'utf-8')) - } catch { - return [] - } + const content = await readLinuxProcNetworkTable(filePath) + return content === null ? [] : parseProcNetTcp(content) } -async function mapLinuxInodesToPids(inodes: Set): Promise> { - const result = new Map() - if (inodes.size === 0) { - return result - } - let pids: string[] - try { - pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry)) - } catch { - return result - } - - for (const pidText of pids) { - let fds: string[] - try { - fds = await readdir(`/proc/${pidText}/fd`) - } catch { - continue - } - const pid = Number.parseInt(pidText, 10) - for (const fd of fds) { - let link: string - try { - link = await readlink(`/proc/${pidText}/fd/${fd}`) - } catch { - continue - } - const match = link.match(/^socket:\[(\d+)\]$/) - if (!match) { - continue - } - const inode = Number.parseInt(match[1], 10) - if (inodes.has(inode)) { - result.set(inode, pid) - } - } - } - return result -} - -async function loadLinuxProcessMetadata(pid: number): Promise { - const [comm, cmdline, cwd] = await Promise.all([ - readTextIfAvailable(`/proc/${pid}/comm`), - readTextIfAvailable(`/proc/${pid}/cmdline`), - readlink(`/proc/${pid}/cwd`).catch(() => undefined) - ]) +async function loadLinuxProcessMetadata( + pid: number, + budget: LinuxProcTextReadBudget +): Promise { + const comm = await readLinuxProcTextWithinBudget(`/proc/${pid}/comm`, budget) + const cmdline = await readLinuxProcTextWithinBudget(`/proc/${pid}/cmdline`, budget) + const cwd = await readlink(`/proc/${pid}/cwd`).catch(() => undefined) return { processName: comm?.trim() || undefined, commandLine: cmdline?.split('\u0000').join(' ').trim() || undefined, @@ -440,14 +412,6 @@ function isCommandTimeoutError(error: unknown): boolean { return error instanceof CommandTimeoutError } -async function readTextIfAvailable(filePath: string): Promise { - try { - return await readFile(filePath, 'utf-8') - } catch { - return undefined - } -} - function enrichPort( port: RawListeningPort, worktrees: readonly NormalizedWorkspacePortProbe[], diff --git a/src/main/providers/filesystem-path-listing-provider.ts b/src/main/providers/filesystem-path-listing-provider.ts new file mode 100644 index 00000000000..951fe981166 --- /dev/null +++ b/src/main/providers/filesystem-path-listing-provider.ts @@ -0,0 +1,7 @@ +export type FilesystemPathListingProvider = { + listFiles( + rootPath: string, + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } + ): Promise + listMarkdownDocuments?(rootPath: string): Promise +} diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 3b864408c46..e31110525fa 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1123,16 +1123,17 @@ export class LocalPtyProvider implements IPtyProvider { this.requestTrackedPtyShutdown(id, proc, operation.immediate) } if (ptyAgentSessionIds.has(id)) { - // Why: POSIX needs a pre-kill descendant snapshot; Windows uses taskkill /T so - // agent/MCP orphans cannot hold the worktree cwd after shell stop (#10004). + // Why: POSIX needs a pre-kill descendant snapshot; Windows tree-kills only when the + // identity probe returns `own` so agent/MCP orphans cannot hold the worktree cwd + // (#10004). `unknown`/`foreign`/`absent` skip taskkill and rely on root close alone. await killWithDescendantSweep(proc.pid, signalRoot, { ownsRoot: () => ptyProcesses.get(id) === proc }) } else if (process.platform === 'win32' && operation.immediate) { // Why: a plain shell's ConPTY teardown doesn't reap orphaned children (useConptyDll // skips the console reap), so a live `pnpm i`/`node` keeps the ConPTY console alive and - // holds the worktree cwd, failing destructive removal. taskkill /T /F clears the tree so - // physical stop is verifiable. POSIX shells reach their child pgroup on forceKill (#10004). + // holds the worktree cwd. Tree kill runs only when the OS identity probe returns `own`; + // otherwise root close alone, and detached children may block physical stop (#10004). await killWithDescendantSweep(proc.pid, signalRoot, { ownsRoot: () => ptyProcesses.get(id) === proc }) diff --git a/src/main/providers/pty-process-inspection.ts b/src/main/providers/pty-process-inspection.ts index 68e174ff886..760095c2e1f 100644 --- a/src/main/providers/pty-process-inspection.ts +++ b/src/main/providers/pty-process-inspection.ts @@ -3,6 +3,7 @@ import type { IPtyProvider } from './types' export type PtyProcessInspection = { foregroundProcess: string | null hasChildProcesses: boolean + unavailable?: true } type CompletionSensitivePtyProvider = IPtyProvider & { diff --git a/src/main/providers/pty-provider-events.ts b/src/main/providers/pty-provider-events.ts index 6de435cb23f..0048b85f9dd 100644 --- a/src/main/providers/pty-provider-events.ts +++ b/src/main/providers/pty-provider-events.ts @@ -1,11 +1,18 @@ import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' +export type PtyDataUpstreamCredit = { + charCount: number + acknowledge(charCount: number): void +} + export type PtyDataEvent = { id: string data: string sequenceChars?: number transformed?: boolean seq?: number + /** Main-process-only credit captured from the exact provider generation that emitted this data. */ + upstreamCredit?: PtyDataUpstreamCredit } /** Notification-bearing fact a thinning transport detected while it held diff --git a/src/main/providers/ssh-agent-session-process-list.ts b/src/main/providers/ssh-agent-session-process-list.ts index cb1447aff71..f5287302ca5 100644 --- a/src/main/providers/ssh-agent-session-process-list.ts +++ b/src/main/providers/ssh-agent-session-process-list.ts @@ -1,25 +1,156 @@ import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' import { isPtyIncarnationId } from '../../shared/pty-incarnation' import type { PtyProcessInfo } from './types' +import { MAX_SSH_PTY_LIVE_ROSTER_ENTRIES } from './ssh-pty-live-roster' +import { admittedSshRelayPtyIdBytes } from './ssh-pty-wire-admission' + +export const MAX_SSH_PTY_PROCESS_LIST_ENTRIES = MAX_SSH_PTY_LIVE_ROSTER_ENTRIES +export const MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY = 256 +export const MAX_SSH_PTY_PROCESS_LIST_OWNERS = MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES +export const MAX_SSH_PTY_PROCESS_LIST_BYTES = 8 * 1024 * 1024 +export const MAX_SSH_PTY_PROCESS_CWD_BYTES = 128 * 1024 +export const MAX_SSH_PTY_PROCESS_TITLE_BYTES = 16 * 1024 +const MAX_SSH_PTY_PROCESS_WORKTREE_ID_BYTES = 128 * 1024 +const MAX_SSH_PTY_PROCESS_TERMINAL_HANDLE_BYTES = 1024 +const MAX_SSH_PTY_PROCESS_WSL_DISTRO_BYTES = 4 * 1024 + +function stringBytes(value: unknown, maxBytes: number): number | null { + if (typeof value !== 'string') { + return null + } + const bytes = Buffer.byteLength(value, 'utf8') + return bytes <= maxBytes ? bytes : null +} + +function optionalStringBytes(value: unknown, maxBytes: number): number | null { + return value === undefined ? 0 : stringBytes(value, maxBytes) +} + +function ownerBytes(owner: unknown): number | null { + if (!isAgentSessionOwnerBinding(owner)) { + return null + } + const strings = [ + owner.claim.keyId, + owner.claim.identityDigest, + owner.claim.worktreeScopeDigest, + owner.claim.agent, + owner.generation, + owner.ptyId, + owner.surface.worktreeId, + owner.surface.tabId, + owner.surface.leafId, + owner.surface.terminalHandle + ] + return strings.reduce((total, value) => total + Buffer.byteLength(value, 'utf8'), 0) +} + +function assertProcessList( + sessions: unknown +): asserts sessions is (PtyProcessInfo & Record)[] { + if (!Array.isArray(sessions) || sessions.length > MAX_SSH_PTY_PROCESS_LIST_ENTRIES) { + throw new Error('invalid_ssh_pty_process_list') + } + let aggregateBytes = 0 + let aggregateOwners = 0 + for (const session of sessions) { + if (typeof session !== 'object' || session === null) { + throw new Error('invalid_ssh_pty_process_list') + } + const idBytes = admittedSshRelayPtyIdBytes(session.id) + const cwdBytes = stringBytes(session.cwd, MAX_SSH_PTY_PROCESS_CWD_BYTES) + const titleBytes = stringBytes(session.title, MAX_SSH_PTY_PROCESS_TITLE_BYTES) + const worktreeBytes = optionalStringBytes( + session.worktreeId, + MAX_SSH_PTY_PROCESS_WORKTREE_ID_BYTES + ) + const terminalHandleBytes = optionalStringBytes( + session.terminalHandle, + MAX_SSH_PTY_PROCESS_TERMINAL_HANDLE_BYTES + ) + const wslDistroBytes = + session.wslDistro === null + ? 0 + : optionalStringBytes(session.wslDistro, MAX_SSH_PTY_PROCESS_WSL_DISTRO_BYTES) + if ( + idBytes === null || + cwdBytes === null || + titleBytes === null || + worktreeBytes === null || + terminalHandleBytes === null || + wslDistroBytes === null || + (session.incarnationId !== undefined && !isPtyIncarnationId(session.incarnationId)) + ) { + throw new Error('invalid_ssh_pty_process_list') + } + aggregateBytes += + idBytes + cwdBytes + titleBytes + worktreeBytes + terminalHandleBytes + wslDistroBytes + if ( + session.agentSessionOwners !== undefined && + (!Array.isArray(session.agentSessionOwners) || + session.agentSessionOwners.length > MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY) + ) { + throw new Error('invalid_ssh_pty_process_list') + } + for (const owner of session.agentSessionOwners ?? []) { + const bytes = ownerBytes(owner) + if (bytes === null || owner.ptyId !== session.id) { + throw new Error('agent_session_ownership_unknown') + } + aggregateBytes += bytes + } + aggregateOwners += session.agentSessionOwners?.length ?? 0 + if ( + aggregateBytes > MAX_SSH_PTY_PROCESS_LIST_BYTES || + aggregateOwners > MAX_SSH_PTY_PROCESS_LIST_OWNERS + ) { + throw new Error('invalid_ssh_pty_process_list') + } + } +} export function mapSshPtyProcessList( - sessions: PtyProcessInfo[], + sessions: unknown, toAppPtyId: (id: string) => string ): PtyProcessInfo[] { + assertProcessList(sessions) return sessions.map((session) => { if (session.agentSessionOwners?.length && !isPtyIncarnationId(session.incarnationId)) { throw new Error('agent_session_ownership_unknown') } return { - ...session, id: toAppPtyId(session.id), - ...(session.agentSessionOwners + cwd: session.cwd, + title: session.title, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(session.worktreeId !== undefined ? { worktreeId: session.worktreeId } : {}), + ...(session.terminalHandle !== undefined ? { terminalHandle: session.terminalHandle } : {}), + ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), + ...(session.agentSessionOwners !== undefined ? { agentSessionOwners: session.agentSessionOwners.map((owner) => { if (!isAgentSessionOwnerBinding(owner) || owner.ptyId !== session.id) { throw new Error('agent_session_ownership_unknown') } - return { ...owner, ptyId: toAppPtyId(owner.ptyId) } + return { + claim: { + digestVersion: owner.claim.digestVersion, + keyId: owner.claim.keyId, + identityDigest: owner.claim.identityDigest, + worktreeScopeDigest: owner.claim.worktreeScopeDigest, + agent: owner.claim.agent + }, + generation: owner.generation, + phase: owner.phase, + ptyId: toAppPtyId(owner.ptyId), + surface: { + worktreeId: owner.surface.worktreeId, + tabId: owner.surface.tabId, + leafId: owner.surface.leafId, + terminalHandle: owner.surface.terminalHandle + } + } }) } : {}) diff --git a/src/main/providers/ssh-filesystem-directory-reader.ts b/src/main/providers/ssh-filesystem-directory-reader.ts new file mode 100644 index 00000000000..5b25a08610f --- /dev/null +++ b/src/main/providers/ssh-filesystem-directory-reader.ts @@ -0,0 +1,59 @@ +import type { DirEntry } from '../../shared/types' +import { + assertFilesystemDirectoryWithinLimit, + resolveFilesystemDirectoryListingLimits +} from '../../shared/filesystem-directory-listing-limit' +import { + assertMobileFileDirectoryWithinLimit, + MOBILE_FILE_DIRECTORY_MAX_ENTRIES, + MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES +} from '../../shared/mobile-file-directory-limit' +import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' + +export const SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE = + 'Safe remote directory browsing requires an updated relay. Reconnect the SSH target before retrying.' + +export class SshFilesystemDirectoryReader { + private boundedMethodUnavailable = false + + constructor(private readonly mux: SshChannelMultiplexer) {} + + async readDir( + dirPath: string, + options?: { maxEntries?: number; maxRetainedBytes?: number } + ): Promise { + if (this.boundedMethodUnavailable) { + throw new Error(SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE) + } + const mobileLimits = + options?.maxEntries === MOBILE_FILE_DIRECTORY_MAX_ENTRIES && + options.maxRetainedBytes === MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES + const limits = mobileLimits + ? { + maxEntries: MOBILE_FILE_DIRECTORY_MAX_ENTRIES, + maxRetainedBytes: MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES + } + : resolveFilesystemDirectoryListingLimits(options) + let entries: DirEntry[] + try { + entries = (await this.mux.request('fs.readDirBounded', { + dirPath, + ...limits + })) as DirEntry[] + } catch (error) { + if (!isMethodNotFoundError(error)) { + throw error + } + // Why: fallback would restore remote-side unbounded enumeration. + this.boundedMethodUnavailable = true + throw new Error(SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE) + } + if (mobileLimits) { + assertMobileFileDirectoryWithinLimit(entries) + } else { + assertFilesystemDirectoryWithinLimit(entries, limits) + } + return entries + } +} diff --git a/src/main/providers/ssh-filesystem-dispatch.test.ts b/src/main/providers/ssh-filesystem-dispatch.test.ts deleted file mode 100644 index 9b932be8c38..00000000000 --- a/src/main/providers/ssh-filesystem-dispatch.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - getSshFilesystemProvider, - onSshFilesystemProviderRegistered, - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from './ssh-filesystem-dispatch' -import type { IFilesystemProvider } from './types' - -const provider = {} as IFilesystemProvider - -describe('onSshFilesystemProviderRegistered', () => { - it('notifies subscribers on every registration, including a reconnect replacing the provider', () => { - const listener = vi.fn() - const unsubscribe = onSshFilesystemProviderRegistered(listener) - - registerSshFilesystemProvider('conn-1', provider) - registerSshFilesystemProvider('conn-1', {} as IFilesystemProvider) - - expect(listener).toHaveBeenCalledTimes(2) - expect(listener).toHaveBeenNthCalledWith(1, 'conn-1') - expect(listener).toHaveBeenNthCalledWith(2, 'conn-1') - - unsubscribe() - unregisterSshFilesystemProvider('conn-1') - }) - - it('exposes the new provider to subscribers while they are being notified', () => { - let seen: IFilesystemProvider | undefined - const unsubscribe = onSshFilesystemProviderRegistered((connectionId) => { - seen = getSshFilesystemProvider(connectionId) - }) - - registerSshFilesystemProvider('conn-2', provider) - - expect(seen).toBe(provider) - unsubscribe() - unregisterSshFilesystemProvider('conn-2') - }) - - it('stops notifying after unsubscribe', () => { - const listener = vi.fn() - onSshFilesystemProviderRegistered(listener)() - - registerSshFilesystemProvider('conn-3', provider) - - expect(listener).not.toHaveBeenCalled() - unregisterSshFilesystemProvider('conn-3') - }) - - it('keeps registration working when a subscriber throws', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const healthy = vi.fn() - const unsubscribeThrower = onSshFilesystemProviderRegistered(() => { - throw new Error('subscriber blew up') - }) - const unsubscribeHealthy = onSshFilesystemProviderRegistered(healthy) - - expect(() => registerSshFilesystemProvider('conn-4', provider)).not.toThrow() - expect(getSshFilesystemProvider('conn-4')).toBe(provider) - expect(healthy).toHaveBeenCalledWith('conn-4') - - unsubscribeThrower() - unsubscribeHealthy() - unregisterSshFilesystemProvider('conn-4') - warnSpy.mockRestore() - }) -}) diff --git a/src/main/providers/ssh-filesystem-dispatch.ts b/src/main/providers/ssh-filesystem-dispatch.ts index 04ef209bef1..6cadd3b7eca 100644 --- a/src/main/providers/ssh-filesystem-dispatch.ts +++ b/src/main/providers/ssh-filesystem-dispatch.ts @@ -5,32 +5,11 @@ const sshProviders = new Map() export const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE = 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' -// Why: a reconnect builds a fresh provider, so anything holding remote state tied to the old -// transport (file watches) needs a signal to rebuild it — nothing else marks that boundary. -const registrationListeners = new Set<(connectionId: string) => void>() - -export function onSshFilesystemProviderRegistered( - listener: (connectionId: string) => void -): () => void { - registrationListeners.add(listener) - return () => { - registrationListeners.delete(listener) - } -} - export function registerSshFilesystemProvider( connectionId: string, provider: IFilesystemProvider ): void { sshProviders.set(connectionId, provider) - for (const listener of registrationListeners) { - try { - listener(connectionId) - } catch (error) { - // Why: relay establish must not fail because a subscriber threw. - console.warn('[ssh-filesystem] provider registration listener failed:', error) - } - } } export function unregisterSshFilesystemProvider(connectionId: string): void { diff --git a/src/main/providers/ssh-filesystem-download.test.ts b/src/main/providers/ssh-filesystem-download.test.ts index d65735cb806..23af1490e7f 100644 --- a/src/main/providers/ssh-filesystem-download.test.ts +++ b/src/main/providers/ssh-filesystem-download.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { downloadFolderViaSftp } from './ssh-filesystem-download' +import { SSH_DIRECTORY_TRANSFER_LIMITS } from '../ssh/ssh-directory-transfer-budget' type SftpEntryKind = 'directory' | 'file' | 'symlink' | 'fifo' @@ -29,6 +30,31 @@ function sftpEntry(filename: string, kind: SftpEntryKind) { return { filename, longname: filename, attrs: sftpStats(kind) } } +function withDirectoryHandles(sftp: Record): unknown { + if (typeof sftp.opendir === 'function' && typeof sftp.close === 'function') { + return sftp + } + const readDirectory = sftp.readdir as ( + path: string, + callback: (error?: Error, value?: unknown) => void + ) => void + const completed = new Set() + return Object.assign(sftp, { + opendir: (path: string, callback: (error: undefined, handle: Buffer) => void) => + callback(undefined, Buffer.from(path)), + readdir: (handle: Buffer, callback: (error?: Error, value?: unknown) => void) => { + const path = handle.toString('utf8') + if (completed.has(path)) { + callback(undefined, false) + return + } + completed.add(path) + readDirectory(path, callback) + }, + close: (_handle: Buffer, callback: (error?: Error) => void) => callback() + }) +} + describe('downloadFolderViaSftp', () => { const roots: string[] = [] @@ -64,7 +90,11 @@ describe('downloadFolderViaSftp', () => { } await expect( - downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination) + downloadFolderViaSftp( + async () => withDirectoryHandles(sftp) as never, + '/remote/src', + destination + ) ).rejects.toThrow("Remote entries map to the same local name 'a.txt'") expect(sftp.fastGet).toHaveBeenCalledTimes(1) }) @@ -83,7 +113,11 @@ describe('downloadFolderViaSftp', () => { } await expect( - downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination) + downloadFolderViaSftp( + async () => withDirectoryHandles(sftp) as never, + '/remote/src', + destination + ) ).rejects.toThrow("Cannot download unsupported remote entry 'build.pipe'") expect(sftp.fastGet).not.toHaveBeenCalled() }) @@ -103,7 +137,11 @@ describe('downloadFolderViaSftp', () => { } await expect( - downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination) + downloadFolderViaSftp( + async () => withDirectoryHandles(sftp) as never, + '/remote/src', + destination + ) ).rejects.toThrow("Cannot download symbolic link 'creds'") // The link target could be /etc/passwd; rejecting from directory-entry // metadata means it is never followed with stat or opened by fastGet. @@ -125,7 +163,11 @@ describe('downloadFolderViaSftp', () => { } await expect( - downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination) + downloadFolderViaSftp( + async () => withDirectoryHandles(sftp) as never, + '/remote/src', + destination + ) ).rejects.toThrow("Remote entries map to the same local name 'download'") expect(sftp.fastGet).not.toHaveBeenCalled() }) @@ -146,9 +188,12 @@ describe('downloadFolderViaSftp', () => { end: vi.fn() } - await downloadFolderViaSftp(async () => sftp as never, sourcePath, destination, { - windowsRemotePaths: false - }) + await downloadFolderViaSftp( + async () => withDirectoryHandles(sftp) as never, + sourcePath, + destination, + { windowsRemotePaths: false } + ) expect(sftp.fastGet).toHaveBeenCalledWith( '/remote/parent\\literal/..\\secret.txt', @@ -171,9 +216,12 @@ describe('downloadFolderViaSftp', () => { } await expect( - downloadFolderViaSftp(async () => sftp as never, 'C:/remote/src', destination, { - windowsRemotePaths: true - }) + downloadFolderViaSftp( + async () => withDirectoryHandles(sftp) as never, + 'C:/remote/src', + destination, + { windowsRemotePaths: true } + ) ).rejects.toThrow("Invalid remote directory entry '..\\secret.txt'") expect(sftp.fastGet).not.toHaveBeenCalled() }) @@ -195,9 +243,12 @@ describe('downloadFolderViaSftp', () => { } const controller = new AbortController() - const result = downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination, { - signal: controller.signal - }) + const result = downloadFolderViaSftp( + async () => withDirectoryHandles(sftp) as never, + '/remote/src', + destination, + { signal: controller.signal } + ) await vi.waitFor(() => expect(sftp.fastGet).toHaveBeenCalledTimes(1)) controller.abort(new Error('renderer closed')) @@ -222,22 +273,26 @@ describe('downloadFolderViaSftp', () => { it('cancels a pending SFTP directory read', async () => { const destination = await createDestination() let readDirCallback: ((error?: Error) => void) | undefined + const readdir = vi.fn((_path: string, callback: (error?: Error) => void) => { + readDirCallback = callback + }) const sftp = { stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => callback(undefined, sftpStats('directory')) ), - readdir: vi.fn((_path: string, callback: (error?: Error) => void) => { - readDirCallback = callback - }), + readdir, fastGet: vi.fn(), end: vi.fn() } const controller = new AbortController() - const result = downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination, { - signal: controller.signal - }) - await vi.waitFor(() => expect(sftp.readdir).toHaveBeenCalledTimes(1)) + const result = downloadFolderViaSftp( + async () => withDirectoryHandles(sftp) as never, + '/remote/src', + destination, + { signal: controller.signal } + ) + await vi.waitFor(() => expect(readdir).toHaveBeenCalledTimes(1)) controller.abort(new Error('renderer closed')) readDirCallback?.(new Error('channel closed')) @@ -245,4 +300,44 @@ describe('downloadFolderViaSftp', () => { expect(sftp.fastGet).not.toHaveBeenCalled() expect(sftp.end).toHaveBeenCalledTimes(1) }) + + it('streams directory chunks and stops at the retained entry budget', async () => { + const destination = await createDestination() + const handle = Buffer.from('handle') + const chunks = [ + [sftpEntry('one.txt', 'file'), sftpEntry('two.txt', 'file')], + [sftpEntry('three.txt', 'file')], + false + ] + const sftp = { + stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => + callback(undefined, sftpStats('directory')) + ), + opendir: vi.fn((_path: string, callback: (err: Error | undefined, value: Buffer) => void) => + callback(undefined, handle) + ), + readdir: vi.fn( + (_handle: Buffer, callback: (err: Error | undefined, value: unknown) => void) => + callback(undefined, chunks.shift()) + ), + close: vi.fn((_handle: Buffer, callback: (err?: Error) => void) => callback()), + fastGet: vi.fn(), + end: vi.fn() + } + + await expect( + downloadFolderViaSftp( + async () => withDirectoryHandles(sftp) as never, + '/remote/src', + destination, + { + limits: { ...SSH_DIRECTORY_TRANSFER_LIMITS, maximumEntries: 2 } + } + ) + ).rejects.toThrow('SSH directory transfer exceeds the entries limit') + + expect(sftp.readdir).toHaveBeenCalledTimes(2) + expect(sftp.close).toHaveBeenCalledWith(handle, expect.any(Function)) + expect(sftp.fastGet).not.toHaveBeenCalled() + }) }) diff --git a/src/main/providers/ssh-filesystem-download.ts b/src/main/providers/ssh-filesystem-download.ts index f73ef4c655f..700216ae036 100644 --- a/src/main/providers/ssh-filesystem-download.ts +++ b/src/main/providers/ssh-filesystem-download.ts @@ -7,12 +7,25 @@ import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' import { sanitizeLocalDownloadFilename } from '../local-download-filename' -import { fastGetViaSftp, readDirViaSftp, statViaSftp } from './ssh-filesystem-provider-sftp' +import { + fastGetViaSftp, + readDirectoryEntriesViaSftp, + statViaSftp +} from './ssh-filesystem-provider-sftp' +import { + SSH_DIRECTORY_TRANSFER_LIMITS, + SshDirectoryTransferBudget, + type SshDirectoryTransferLimits +} from '../ssh/ssh-directory-transfer-budget' export type SftpFactory = (options?: { signal?: AbortSignal }) => Promise /** When known, windowsRemotePaths drives remote path joining; omit uses path-shape heuristics. */ -export type FolderDownloadOptions = { signal?: AbortSignal; windowsRemotePaths?: boolean } +export type FolderDownloadOptions = { + signal?: AbortSignal + windowsRemotePaths?: boolean + limits?: SshDirectoryTransferLimits +} const DOWNLOAD_UNAVAILABLE_MESSAGE = 'Remote folder download is unavailable. Reconnect the SSH target and retry.' @@ -82,13 +95,11 @@ async function downloadDirectoryTree( sftp: SFTPWrapper, sourceDir: string, destinationDir: string, + budget: SshDirectoryTransferBudget, + depth: number, signal?: AbortSignal, windowsRemotePaths?: boolean ): Promise { - signal?.throwIfAborted() - const entries = (await readDirViaSftp(sftp, sourceDir, { signal })).filter( - (entry) => entry.filename !== '.' && entry.filename !== '..' - ) signal?.throwIfAborted() const usedLocalNames = new Set() const plannedEntries: { @@ -96,7 +107,13 @@ async function downloadDirectoryTree( kind: 'directory' | 'file' localName: string }[] = [] - for (const entry of entries) { + for await (const entry of readDirectoryEntriesViaSftp(sftp, sourceDir, { signal })) { + signal?.throwIfAborted() + if (entry.filename === '.' || entry.filename === '..') { + continue + } + const remotePath = joinSftpChildPath(sourceDir, entry.filename, windowsRemotePaths) + budget.recordPath(remotePath, depth + 1) const localName = sanitizeLocalDownloadFilename(entry.filename) if (usedLocalNames.has(localName)) { throw new Error(`Remote entries map to the same local name '${localName}'`) @@ -115,7 +132,15 @@ async function downloadDirectoryTree( const remotePath = joinSftpChildPath(sourceDir, entry.filename, windowsRemotePaths) const localPath = join(destinationDir, localName) if (kind === 'directory') { - await downloadDirectoryTree(sftp, remotePath, localPath, signal, windowsRemotePaths) + await downloadDirectoryTree( + sftp, + remotePath, + localPath, + budget, + depth + 1, + signal, + windowsRemotePaths + ) continue } // Why: filesystem semantics belong to the selected volume, not the host OS; @@ -170,10 +195,14 @@ export async function downloadFolderViaSftp( if (!rootStats.isDirectory()) { throw new Error('Cannot download a file as a folder') } + const budget = new SshDirectoryTransferBudget(options?.limits ?? SSH_DIRECTORY_TRANSFER_LIMITS) + budget.recordPath(sourcePath, 0, { countEntry: false }) await downloadDirectoryTree( sftp, sourcePath, destinationPath, + budget, + 0, signal, options?.windowsRemotePaths ) diff --git a/src/main/providers/ssh-filesystem-provider-directory-limits.test.ts b/src/main/providers/ssh-filesystem-provider-directory-limits.test.ts new file mode 100644 index 00000000000..36f39f59d6a --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider-directory-limits.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { + FILESYSTEM_DIRECTORY_MAX_ENTRIES, + FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES +} from '../../shared/filesystem-directory-listing-limit' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' +import { SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE } from './ssh-filesystem-directory-reader' +import { SshFilesystemProvider } from './ssh-filesystem-provider' + +function createProvider(request: ReturnType): SshFilesystemProvider { + return new SshFilesystemProvider('conn-1', { + request, + onNotification: vi.fn(() => () => {}) + } as never) +} + +describe('SshFilesystemProvider directory limits', () => { + it('routes normal listings through the bounded method without changing results', async () => { + const entries = [ + { name: 'src', isDirectory: true, isSymlink: false }, + { name: 'README.md', isDirectory: false, isSymlink: false } + ] + const request = vi.fn().mockResolvedValue(entries) + const provider = createProvider(request) + + await expect(provider.readDir('/home/user/project')).resolves.toEqual(entries) + expect(request).toHaveBeenCalledWith('fs.readDirBounded', { + dirPath: '/home/user/project', + maxEntries: FILESYSTEM_DIRECTORY_MAX_ENTRIES, + maxRetainedBytes: FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES + }) + }) + + it('forwards stricter mobile directory retention limits', async () => { + const request = vi.fn().mockResolvedValue([]) + const provider = createProvider(request) + + await provider.readDir('/home/user/project', { + maxEntries: 10_000, + maxRetainedBytes: 4 * 1024 * 1024 + }) + + expect(request).toHaveBeenCalledWith('fs.readDirBounded', { + dirPath: '/home/user/project', + maxEntries: 10_000, + maxRetainedBytes: 4 * 1024 * 1024 + }) + }) + + it('validates bounded relay results before returning them', async () => { + const request = vi.fn().mockResolvedValue([ + { name: 'one', isDirectory: false, isSymlink: false }, + { name: 'two', isDirectory: false, isSymlink: false }, + { name: 'three', isDirectory: false, isSymlink: false } + ]) + const provider = createProvider(request) + + await expect( + provider.readDir('/home/user/project', { maxEntries: 2, maxRetainedBytes: 1024 }) + ).rejects.toThrow('This folder is too large to list safely') + }) + + it('requires reconnect instead of falling back to an unbounded old relay', async () => { + const request = vi + .fn() + .mockRejectedValue( + Object.assign(new Error('Method not found'), { code: JsonRpcErrorCode.MethodNotFound }) + ) + const provider = createProvider(request) + + await expect(provider.readDir('/home/user/project')).rejects.toThrow( + SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE + ) + await expect(provider.readDir('/home/user/project')).rejects.toThrow( + SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE + ) + expect(request).toHaveBeenCalledTimes(1) + expect(request).not.toHaveBeenCalledWith('fs.readDir', expect.anything()) + }) +}) diff --git a/src/main/providers/ssh-filesystem-provider-download-folder.test.ts b/src/main/providers/ssh-filesystem-provider-download-folder.test.ts index c875301883d..e5b2979f0f5 100644 --- a/src/main/providers/ssh-filesystem-provider-download-folder.test.ts +++ b/src/main/providers/ssh-filesystem-provider-download-folder.test.ts @@ -29,6 +29,29 @@ function sftpEntry(filename: string, kind: SftpEntryKind) { return { filename, longname: filename, attrs: sftpStats(kind) } } +function createSftpDirectoryHandleMethods( + readEntries: (remotePath: string) => ReturnType[] +) { + const completedPaths = new Set() + return { + opendir: vi.fn((remotePath: string, callback: (error: undefined, handle: Buffer) => void) => + callback(undefined, Buffer.from(remotePath)) + ), + readdir: vi.fn( + (handle: Buffer, callback: (error?: Error & { code?: number }, value?: unknown) => void) => { + const remotePath = handle.toString('utf8') + if (completedPaths.has(remotePath)) { + callback(Object.assign(new Error('EOF'), { code: 1 })) + return + } + completedPaths.add(remotePath) + callback(undefined, readEntries(remotePath)) + } + ), + close: vi.fn((_handle: Buffer, callback: (error?: Error) => void) => callback()) + } +} + function createMockMux() { return { request: vi.fn(), @@ -69,14 +92,10 @@ describe('SshFilesystemProvider downloadFolder', () => { (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => callback(undefined, sftpStats(remotePath === '/remote/src' ? 'directory' : 'file')) ), - readdir: vi.fn( - (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => - callback( - undefined, - remotePath === '/remote/src' - ? [sftpEntry('index.ts', 'file'), sftpEntry('lib', 'directory')] - : [sftpEntry('a.ts', 'file')] - ) + ...createSftpDirectoryHandleMethods((remotePath) => + remotePath === '/remote/src' + ? [sftpEntry('index.ts', 'file'), sftpEntry('lib', 'directory')] + : [sftpEntry('a.ts', 'file')] ), fastGet: vi.fn((_source: string, _destination: string, callback: (err?: Error) => void) => callback() @@ -95,6 +114,7 @@ describe('SshFilesystemProvider downloadFolder', () => { ['/remote/src/lib/a.ts', join(destination, 'lib', 'a.ts')] ]) await expect(stat(join(destination, 'lib'))).resolves.toMatchObject({}) + expect(sftp.close).toHaveBeenCalledTimes(2) expect(sftp.end).toHaveBeenCalledTimes(1) expect(mux.request).not.toHaveBeenCalled() }) @@ -107,9 +127,7 @@ describe('SshFilesystemProvider downloadFolder', () => { (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => callback(undefined, sftpStats(remotePath === '/remote/src' ? 'directory' : 'directory')) ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('linked-dir', 'symlink')]) - ), + ...createSftpDirectoryHandleMethods(() => [sftpEntry('linked-dir', 'symlink')]), fastGet: vi.fn(), end: vi.fn() } @@ -120,6 +138,7 @@ describe('SshFilesystemProvider downloadFolder', () => { ) expect(sftp.fastGet).not.toHaveBeenCalled() + expect(sftp.close).toHaveBeenCalledTimes(1) expect(sftp.end).toHaveBeenCalledTimes(1) }) @@ -130,9 +149,10 @@ describe('SshFilesystemProvider downloadFolder', () => { stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => callback(undefined, sftpStats('directory')) ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('a:b.txt', 'file'), sftpEntry('a?b.txt', 'file')]) - ), + ...createSftpDirectoryHandleMethods(() => [ + sftpEntry('a:b.txt', 'file'), + sftpEntry('a?b.txt', 'file') + ]), fastGet: vi.fn(), end: vi.fn() } @@ -143,6 +163,7 @@ describe('SshFilesystemProvider downloadFolder', () => { ) expect(sftp.fastGet).not.toHaveBeenCalled() + expect(sftp.close).toHaveBeenCalledTimes(1) expect(sftp.end).toHaveBeenCalledTimes(1) }) diff --git a/src/main/providers/ssh-filesystem-provider-sftp.ts b/src/main/providers/ssh-filesystem-provider-sftp.ts index a3b1b840be3..af1af6c2ac2 100644 --- a/src/main/providers/ssh-filesystem-provider-sftp.ts +++ b/src/main/providers/ssh-filesystem-provider-sftp.ts @@ -95,15 +95,38 @@ export function fastGetViaSftp( ) } -export function readDirViaSftp( +export async function* readDirectoryEntriesViaSftp( sftp: SFTPWrapper, dirPath: string, options?: { signal?: AbortSignal } -): Promise { - return waitForSftpCallback( - (callback) => sftp.readdir(dirPath, callback), +): AsyncGenerator { + const handle = await waitForSftpCallback( + (callback) => sftp.opendir(dirPath, callback), options ) + try { + for (;;) { + const entries = await waitForSftpCallback( + (callback) => + sftp.readdir(handle, (error, value) => { + if ((error as { code?: number } | undefined)?.code === 1) { + callback(null, false) + return + } + callback(error, value && value.length > 0 ? value : false) + }), + options + ) + if (entries === false) { + break + } + yield* entries + } + } finally { + await waitForSftpCallback((callback) => sftp.close(handle, callback), options).catch( + () => undefined + ) + } } export function statViaSftp( diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts index b66b5633503..2563fbeaf7b 100644 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -105,6 +105,54 @@ describe('SshFilesystemProvider readFile streaming', () => { expect(result).toEqual(legacyResult) }) + it('propagates validated raster dimensions from stream metadata', async () => { + const content = Buffer.from('png-header') + mux.request.mockImplementation(async () => { + setImmediate(() => { + mux._emitMethod('fs.streamChunk', { + streamId: 3, + seq: 0, + data: content.toString('base64') + }) + mux._emitMethod('fs.streamEnd', { streamId: 3 }) + }) + return { + streamId: 3, + totalSize: content.length, + isBinary: true, + isImage: true, + mimeType: 'image/png', + imageDimensions: { width: 640, height: 480 }, + resultEncoding: 'base64' + } + }) + + await expect(provider.readFile('/home/image.png')).resolves.toEqual({ + content: content.toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png', + imageDimensions: { width: 640, height: 480 } + }) + }) + + it('rejects unsafe raster dimensions in stream metadata', async () => { + mux.request.mockResolvedValue({ + streamId: 4, + totalSize: 1, + isBinary: true, + isImage: true, + mimeType: 'image/png', + imageDimensions: { width: 32_769, height: 1 }, + resultEncoding: 'base64' + }) + + await expect(provider.readFile('/home/bomb.png')).rejects.toThrow( + 'Malformed file stream metadata' + ) + expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 4 }) + }) + it('rejects when chunk arrives out of order', async () => { const totalSize = 256 * 1024 * 2 mux.request.mockImplementation(async () => { @@ -178,7 +226,7 @@ describe('SshFilesystemProvider readFile streaming', () => { resultEncoding: 'base64' } }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/count mismatch/i) + await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/incomplete/i) }) it('rejects a short final chunk instead of zero-filling the buffer', async () => { diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index a7061616c4a..e8409249c0c 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -63,20 +63,6 @@ describe('SshFilesystemProvider', () => { expect(provider.getConnectionId()).toBe('conn-1') }) - describe('readDir', () => { - it('sends fs.readDir request', async () => { - const entries = [ - { name: 'src', isDirectory: true, isSymlink: false }, - { name: 'README.md', isDirectory: false, isSymlink: false } - ] - mux.request.mockResolvedValue(entries) - - const result = await provider.readDir('/home/user/project') - expect(mux.request).toHaveBeenCalledWith('fs.readDir', { dirPath: '/home/user/project' }) - expect(result).toEqual(entries) - }) - }) - describe('readFile', () => { it('short-circuits on empty:true metadata without subscribing to chunks', async () => { mux.request.mockResolvedValue({ totalSize: 0, isBinary: false, empty: true }) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 74f31b8e6bf..231e695069b 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -25,6 +25,8 @@ import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../.. import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import { isWindowsRemoteHost, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { SshFilesystemDirectoryReader } from './ssh-filesystem-directory-reader' +import { requestSshMarkdownDocumentPaths } from './ssh-markdown-document-listing' const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 export class SshFilesystemProvider implements IFilesystemProvider { @@ -35,6 +37,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { private tempDirPromise: Promise | null = null private disposed = false private loggedStreamFallback = false + private readonly directoryReader: SshFilesystemDirectoryReader readonly downloadFolder?: IFilesystemProvider['downloadFolder'] constructor( @@ -46,6 +49,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { ) { this.connectionId = connectionId this.mux = mux + this.directoryReader = new SshFilesystemDirectoryReader(mux) if (createSftp) { // Why: system SSH has raw single-file transfer but no ssh2 SFTP channel; @@ -83,8 +87,11 @@ export class SshFilesystemProvider implements IFilesystemProvider { return this.connectionId } - async readDir(dirPath: string): Promise { - return (await this.mux.request('fs.readDir', { dirPath })) as DirEntry[] + async readDir( + dirPath: string, + options?: { maxEntries?: number; maxRetainedBytes?: number } + ): Promise { + return this.directoryReader.readDir(dirPath, options) } async readFile(filePath: string): Promise { @@ -319,6 +326,8 @@ export class SshFilesystemProvider implements IFilesystemProvider { })) as string[] } + listMarkdownDocuments = (rootPath: string) => requestSshMarkdownDocumentPaths(this.mux, rootPath) + async watch( rootPath: string, callback: (events: FsChangeEvent[]) => void, diff --git a/src/main/providers/ssh-markdown-document-listing.test.ts b/src/main/providers/ssh-markdown-document-listing.test.ts new file mode 100644 index 00000000000..71272b09c5a --- /dev/null +++ b/src/main/providers/ssh-markdown-document-listing.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import { MarkdownDocumentListingCapacityError } from '../../shared/markdown-document-listing-limits' +import { JsonRpcErrorCode, RelayErrorCode } from '../ssh/relay-protocol' +import { requestSshMarkdownDocumentPaths } from './ssh-markdown-document-listing' + +describe('SSH Markdown document listing', () => { + it('requests producer-filtered Markdown paths', async () => { + const request = vi.fn().mockResolvedValue(['README.md', 'docs/guide.mdx']) + + await expect( + requestSshMarkdownDocumentPaths({ request } as never, '/home/user/project') + ).resolves.toEqual(['README.md', 'docs/guide.mdx']) + expect(request).toHaveBeenCalledWith('fs.listMarkdownDocuments', { + rootPath: '/home/user/project' + }) + }) + + it('surfaces typed producer capacity failures', async () => { + const request = vi.fn().mockRejectedValue( + Object.assign(new Error('capacity'), { + code: RelayErrorCode.MarkdownDocumentListingCapacity + }) + ) + + await expect( + requestSshMarkdownDocumentPaths({ request } as never, '/home/user/project') + ).rejects.toBeInstanceOf(MarkdownDocumentListingCapacityError) + }) + + it('requires reconnect instead of using an unbounded old-relay fallback', async () => { + const request = vi + .fn() + .mockRejectedValue( + Object.assign(new Error('missing'), { code: JsonRpcErrorCode.MethodNotFound }) + ) + + await expect( + requestSshMarkdownDocumentPaths({ request } as never, '/home/user/project') + ).rejects.toThrow('Reconnect the SSH target') + expect(request).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/providers/ssh-markdown-document-listing.ts b/src/main/providers/ssh-markdown-document-listing.ts new file mode 100644 index 00000000000..9061b98aac6 --- /dev/null +++ b/src/main/providers/ssh-markdown-document-listing.ts @@ -0,0 +1,27 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' +import { RelayErrorCode } from '../ssh/relay-protocol' +import { MarkdownDocumentListingCapacityError } from '../../shared/markdown-document-listing-limits' + +export async function requestSshMarkdownDocumentPaths( + mux: SshChannelMultiplexer, + rootPath: string +): Promise { + try { + return (await mux.request('fs.listMarkdownDocuments', { rootPath })) as string[] + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error as { code?: unknown }).code === RelayErrorCode.MarkdownDocumentListingCapacity + ) { + throw new MarkdownDocumentListingCapacityError() + } + if (isMethodNotFoundError(error)) { + throw new Error( + 'Remote Markdown link discovery is unavailable. Reconnect the SSH target and retry.' + ) + } + throw error + } +} diff --git a/src/main/providers/ssh-pty-live-roster.test.ts b/src/main/providers/ssh-pty-live-roster.test.ts new file mode 100644 index 00000000000..aac1d5adcf4 --- /dev/null +++ b/src/main/providers/ssh-pty-live-roster.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_SSH_PTY_LIVE_ROSTER_ENTRIES, + MAX_SSH_PTY_LIVE_ROSTER_ID_BYTES, + SshPtyLiveRoster +} from './ssh-pty-live-roster' +import { MAX_SSH_APP_PTY_ID_BYTES } from './ssh-pty-wire-admission' + +describe('SshPtyLiveRoster', () => { + it('preserves ordinary listing and exit behavior', () => { + const roster = new SshPtyLiveRoster() + const listing = roster.beginListing() + + roster.reconcileListing(listing, ['pty-1']) + expect(roster.has('pty-1')).toBe(true) + + roster.recordExit('pty-1') + expect(roster.has('pty-1')).toBe(false) + }) + + it('gives a locally spawned PTY one stale-list grace', () => { + const roster = new SshPtyLiveRoster() + roster.recordSpawn('pty-new') + + roster.reconcileListing(roster.beginListing(), []) + expect(roster.has('pty-new')).toBe(true) + + roster.reconcileListing(roster.beginListing(), []) + expect(roster.has('pty-new')).toBe(false) + }) + + it('keeps a PTY observed while a listing is in flight', () => { + const roster = new SshPtyLiveRoster() + const listing = roster.beginListing() + + roster.recordNotification('pty-new') + roster.reconcileListing(listing, []) + + expect(roster.has('pty-new')).toBe(true) + }) + + it('ignores an older listing after a newer listing reconciles', () => { + const roster = new SshPtyLiveRoster() + roster.recordNotification('pty-live') + const older = roster.beginListing() + const newer = roster.beginListing() + + roster.reconcileListing(newer, ['pty-live']) + roster.reconcileListing(older, []) + + expect(roster.has('pty-live')).toBe(true) + }) + + it('does not resurrect an exit with an in-flight stale listing', () => { + const roster = new SshPtyLiveRoster() + roster.recordNotification('pty-exited') + const listing = roster.beginListing() + + roster.recordExit('pty-exited') + roster.reconcileListing(listing, ['pty-exited']) + + expect(roster.has('pty-exited')).toBe(false) + }) + + it('caps missed-exit unique-id churn and evicts the oldest live row', () => { + const roster = new SshPtyLiveRoster() + const total = MAX_SSH_PTY_LIVE_ROSTER_ENTRIES + 50 + + for (let index = 0; index < total; index += 1) { + roster.recordNotification(`pty-${index}`) + } + + expect(roster.has('pty-0')).toBe(false) + expect(roster.has(`pty-${total - MAX_SSH_PTY_LIVE_ROSTER_ENTRIES}`)).toBe(true) + expect(roster.has(`pty-${total - 1}`)).toBe(true) + }) + + it('does not let exit fences evict supported live entries', () => { + const roster = new SshPtyLiveRoster() + for (let index = 0; index < 50; index += 1) { + roster.recordNotification(`live-${index}`) + } + for (let index = 0; index < MAX_SSH_PTY_LIVE_ROSTER_ENTRIES * 2; index += 1) { + roster.recordExit(`gone-${index}`) + } + + for (let index = 0; index < 50; index += 1) { + expect(roster.has(`live-${index}`)).toBe(true) + } + }) + + it('rejects oversized ids and caps aggregate retained id bytes', () => { + const roster = new SshPtyLiveRoster() + const oversized = 'x'.repeat(MAX_SSH_APP_PTY_ID_BYTES + 1) + roster.recordNotification(oversized) + expect(roster.has(oversized)).toBe(false) + + const payload = 'x'.repeat(16 * 1024 - 32) + const total = Math.floor(MAX_SSH_PTY_LIVE_ROSTER_ID_BYTES / Buffer.byteLength(payload)) + 2 + for (let index = 0; index < total; index += 1) { + roster.recordNotification(`${index}:${payload}`) + } + + expect(roster.has(`0:${payload}`)).toBe(false) + expect(roster.has(`${total - 1}:${payload}`)).toBe(true) + }) + + it('ignores a listing response that completes after clear', () => { + const roster = new SshPtyLiveRoster() + const listing = roster.beginListing() + + roster.clear() + roster.reconcileListing(listing, ['pty-stale']) + + expect(roster.has('pty-stale')).toBe(false) + }) + + it('does not resurrect an exit after its tombstone is evicted', () => { + const roster = new SshPtyLiveRoster() + roster.recordSpawn('pty-reused') + const stale = roster.beginListing() + roster.recordExit('pty-reused') + for (let index = 0; index < MAX_SSH_PTY_LIVE_ROSTER_ENTRIES; index += 1) { + roster.recordExit(`gone-${index}`) + } + + roster.reconcileListing(stale, ['pty-reused']) + expect(roster.has('pty-reused')).toBe(false) + + const fresh = roster.beginListing() + roster.reconcileListing(fresh, ['pty-reused']) + expect(roster.has('pty-reused')).toBe(true) + }) +}) diff --git a/src/main/providers/ssh-pty-live-roster.ts b/src/main/providers/ssh-pty-live-roster.ts new file mode 100644 index 00000000000..cafbdb53e5e --- /dev/null +++ b/src/main/providers/ssh-pty-live-roster.ts @@ -0,0 +1,155 @@ +import { admittedSshAppPtyIdBytes } from './ssh-pty-wire-admission' + +export const MAX_SSH_PTY_LIVE_ROSTER_ENTRIES = 256 +export const MAX_SSH_PTY_LIVE_ROSTER_ID_BYTES = 2 * 1024 * 1024 + +export type SshPtyListingToken = { + sequence: number + observationRevision: number +} + +type RosterEntry = { + idBytes: number + live: boolean + missingListingGrace: boolean + observationRevision: number +} + +export class SshPtyLiveRoster { + private readonly entries = new Map() + private observationRevision = 0 + private listingSequence = 0 + private latestReconciledListing = 0 + private retainedIdBytes = 0 + private staleListingFenceRevision = 0 + + beginListing(): SshPtyListingToken { + return { + sequence: ++this.listingSequence, + observationRevision: this.observationRevision + } + } + + recordSpawn(id: string): void { + this.record(id, true) + } + + recordNotification(id: string): void { + this.record(id, false) + } + + recordExit(id: string): void { + const idBytes = admittedSshAppPtyIdBytes(id) + if (idBytes === null) { + return + } + this.setEntry(id, { + idBytes, + live: false, + missingListingGrace: false, + observationRevision: ++this.observationRevision + }) + } + + reconcileListing(token: SshPtyListingToken, listedIds: readonly string[]): void { + if (token.sequence <= this.latestReconciledListing) { + return + } + this.latestReconciledListing = token.sequence + if (token.observationRevision < this.staleListingFenceRevision) { + return + } + const listed = new Set() + for (const id of listedIds) { + if (listed.size < MAX_SSH_PTY_LIVE_ROSTER_ENTRIES && admittedSshAppPtyIdBytes(id) !== null) { + listed.add(id) + } + } + + for (const [id, entry] of this.entries) { + if (!entry.live || listed.has(id) || entry.observationRevision > token.observationRevision) { + continue + } + if (entry.missingListingGrace) { + entry.missingListingGrace = false + entry.observationRevision = ++this.observationRevision + continue + } + this.removeEntry(id) + } + + for (const id of listed) { + const current = this.entries.get(id) + // Why: an exit newer than this listing request must fence its stale response. + if (current && !current.live && current.observationRevision > token.observationRevision) { + continue + } + this.record(id, false) + } + } + + has(id: string): boolean { + return this.entries.get(id)?.live === true + } + + clear(): void { + this.entries.clear() + this.retainedIdBytes = 0 + // Why: responses from listings already in flight must not repopulate a disposed provider. + this.latestReconciledListing = this.listingSequence + } + + private record(id: string, missingListingGrace: boolean): void { + const idBytes = admittedSshAppPtyIdBytes(id) + if (idBytes === null) { + return + } + this.setEntry(id, { + idBytes, + live: true, + missingListingGrace, + observationRevision: ++this.observationRevision + }) + } + + private setEntry(id: string, entry: RosterEntry): void { + this.removeEntry(id) + this.entries.set(id, entry) + this.retainedIdBytes += entry.idBytes + this.capEntries() + } + + private capEntries(): void { + while ( + this.entries.size > MAX_SSH_PTY_LIVE_ROSTER_ENTRIES || + this.retainedIdBytes > MAX_SSH_PTY_LIVE_ROSTER_ID_BYTES + ) { + let inactive: string | undefined + for (const [id, entry] of this.entries) { + if (!entry.live) { + inactive = id + break + } + } + const oldest = this.entries.keys().next().value as string | undefined + const evictedId = inactive ?? oldest ?? '' + const evicted = this.entries.get(evictedId) + if (evicted && !evicted.live) { + this.staleListingFenceRevision = Math.max( + this.staleListingFenceRevision, + evicted.observationRevision + ) + } + this.removeEntry(evictedId) + } + } + + private removeEntry(id: string): void { + const entry = this.entries.get(id) + if (!entry) { + return + } + this.retainedIdBytes -= entry.idBytes + this.entries.delete(id) + } +} diff --git a/src/main/providers/ssh-pty-notification-routing.ts b/src/main/providers/ssh-pty-notification-routing.ts deleted file mode 100644 index 63721877f98..00000000000 --- a/src/main/providers/ssh-pty-notification-routing.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isPtyIncarnationId } from '../../shared/pty-incarnation' -import type { - SshPtyDataCallback, - SshPtyExitCallback, - SshPtyReplayCallback -} from './ssh-pty-provider-contract' - -export type { SshPtyDataCallback, SshPtyExitCallback, SshPtyReplayCallback } - -export function subscribeSshPtyNotifications(args: { - mux: SshChannelMultiplexer - toAppPtyId: (id: string) => string - dataListeners: Set - replayListeners: Set - exitListeners: Set - livePtyIds: Set - recordExit: (relayPtyId: string, incarnationId: unknown) => void -}): () => void { - return args.mux.onNotification((method, params) => { - const id = args.toAppPtyId(params.id as string) - if (method === 'pty.exit') { - args.recordExit(params.id as string, params.incarnationId) - args.livePtyIds.delete(id) - for (const listener of args.exitListeners) { - listener({ - id, - code: params.code as number, - ...(isPtyIncarnationId(params.incarnationId) - ? { incarnationId: params.incarnationId } - : {}) - }) - } - return - } - if (method !== 'pty.data' && method !== 'pty.replay') { - return - } - args.livePtyIds.add(id) - if (method === 'pty.replay') { - for (const listener of args.replayListeners) { - listener({ id, data: params.data as string }) - } - return - } - for (const listener of args.dataListeners) { - listener({ - id, - data: params.data as string, - ...(typeof params.rawLength === 'number' ? { sequenceChars: params.rawLength } : {}), - ...(params.transformed === true ? { transformed: true } : {}), - ...(typeof params.seq === 'number' ? { seq: params.seq } : {}) - }) - } - }) -} diff --git a/src/main/providers/ssh-pty-output-delivery-credit.test.ts b/src/main/providers/ssh-pty-output-delivery-credit.test.ts new file mode 100644 index 00000000000..e3df99902c9 --- /dev/null +++ b/src/main/providers/ssh-pty-output-delivery-credit.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MAX_SSH_PTY_CREDIT_SEGMENTS, + MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE, + MAX_SSH_PTY_CREDIT_STATE_ID_BYTES, + MAX_SSH_PTY_CREDIT_STATES, + SshPtyOutputDeliveryCredit +} from './ssh-pty-output-delivery-credit' +import { + MAX_SSH_PTY_DELIVERY_TOKEN_BYTES, + MAX_SSH_RELAY_PTY_ID_BYTES +} from './ssh-pty-wire-admission' + +function retainedStates(credit: SshPtyOutputDeliveryCredit): Map { + return Reflect.get(credit, 'states') as Map +} + +describe('SshPtyOutputDeliveryCredit', () => { + it('defers tokenized credit until the captured owner acknowledges renderer progress', () => { + const notifyAck = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(notifyAck) + let upstreamCredit: { charCount: number; acknowledge(chars: number): void } | undefined + + credit.ingest( + { + id: 'pty-1', + data: 'output', + rawLength: 10, + deliveryToken: 'delivery-1' + }, + (output) => { + upstreamCredit = output.upstreamCredit + } + ) + + expect(notifyAck).not.toHaveBeenCalled() + upstreamCredit?.acknowledge(4) + upstreamCredit?.acknowledge(20) + + expect(notifyAck).toHaveBeenNthCalledWith(1, { + id: 'pty-1', + charCount: 4, + deliveryToken: 'delivery-1' + }) + expect(notifyAck).toHaveBeenNthCalledWith(2, { + id: 'pty-1', + charCount: 6, + deliveryToken: 'delivery-1' + }) + }) + + it('acknowledges a later dropped segment without consuming earlier visible credit', () => { + const notifyAck = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(notifyAck) + let first: { acknowledge(chars: number): void } | undefined + let second: { acknowledge(chars: number): void } | undefined + credit.ingest({ id: 'pty-1', data: 'first', deliveryToken: 'delivery-1' }, (output) => { + first = output.upstreamCredit + }) + credit.ingest({ id: 'pty-1', data: 'second', deliveryToken: 'delivery-1' }, (output) => { + second = output.upstreamCredit + }) + + second?.acknowledge(6) + first?.acknowledge(5) + + expect(notifyAck.mock.calls).toEqual([ + [{ id: 'pty-1', charCount: 6, deliveryToken: 'delivery-1' }], + [{ id: 'pty-1', charCount: 5, deliveryToken: 'delivery-1' }] + ]) + }) + + it('walks token segments in order for legacy provider acknowledgement calls', () => { + const notifyAck = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(notifyAck) + credit.ingest({ id: 'pty-1', data: 'first', deliveryToken: 'token-1' }, () => {}) + credit.ingest({ id: 'pty-1', data: 'second', deliveryToken: 'token-1' }, () => {}) + + credit.acknowledgeLegacy('pty-1', 11) + + expect(notifyAck.mock.calls).toEqual([ + [{ id: 'pty-1', charCount: 5, deliveryToken: 'token-1' }], + [{ id: 'pty-1', charCount: 6, deliveryToken: 'token-1' }] + ]) + }) + + it('drops stale credit when attach rotates the delivery token', () => { + const notifyAck = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(notifyAck) + let stale: { acknowledge(chars: number): void } | undefined + let current: { acknowledge(chars: number): void } | undefined + credit.ingest({ id: 'pty-1', data: 'stale', deliveryToken: 'before-attach' }, (output) => { + stale = output.upstreamCredit + }) + credit.ingest({ id: 'pty-1', data: 'current', deliveryToken: 'after-attach' }, (output) => { + current = output.upstreamCredit + }) + + current?.acknowledge(7) + stale?.acknowledge(5) + credit.ingest({ id: 'pty-1', data: 'next', deliveryToken: 'after-attach' }, () => {}) + credit.acknowledgeLegacy('pty-1', 4) + + expect(notifyAck.mock.calls).toEqual([ + [{ id: 'pty-1', charCount: 7, deliveryToken: 'after-attach' }], + [{ id: 'pty-1', charCount: 4, deliveryToken: 'after-attach' }] + ]) + }) + + it('preserves legacy no-token acknowledgements', () => { + const notifyAck = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(notifyAck) + credit.ingest({ id: 'pty-1', data: 'legacy' }, () => {}) + + credit.acknowledgeLegacy('pty-1', 6) + + expect(notifyAck).toHaveBeenCalledWith({ id: 'pty-1', charCount: 6 }) + }) + + it('falls back to delivered length when raw length is invalid', () => { + const notifyAck = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(notifyAck) + let upstreamCredit: { acknowledge(chars: number): void } | undefined + credit.ingest( + { + id: 'pty-1', + data: 'output', + rawLength: Number.POSITIVE_INFINITY, + deliveryToken: 'delivery-2' + }, + (output) => { + upstreamCredit = output.upstreamCredit + } + ) + + upstreamCredit?.acknowledge(100) + + expect(notifyAck).toHaveBeenCalledWith({ + id: 'pty-1', + charCount: 6, + deliveryToken: 'delivery-2' + }) + }) + + it('makes captured credit inert after exit or disposal', () => { + const notifyAck = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(notifyAck) + let exited: { acknowledge(chars: number): void } | undefined + let disposed: { acknowledge(chars: number): void } | undefined + credit.ingest({ id: 'pty-1', data: 'exit', deliveryToken: 'delivery-3' }, (output) => { + exited = output.upstreamCredit + }) + credit.release('pty-1') + exited?.acknowledge(4) + credit.ingest({ id: 'pty-2', data: 'dispose', deliveryToken: 'delivery-4' }, (output) => { + disposed = output.upstreamCredit + }) + + credit.dispose() + disposed?.acknowledge(7) + + expect(notifyAck).not.toHaveBeenCalled() + }) + + it('returns delivery credit when listener ingestion throws', () => { + const notifyAck = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(notifyAck) + + expect(() => + credit.ingest({ id: 'pty-1', data: 'output', deliveryToken: 'delivery-5' }, () => { + throw new Error('listener failed') + }) + ).toThrow('listener failed') + expect(notifyAck).toHaveBeenCalledWith({ + id: 'pty-1', + charCount: 6, + deliveryToken: 'delivery-5' + }) + }) + + it('rejects oversized ids and delivery tokens before retention', () => { + const deliver = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(vi.fn()) + + credit.ingest( + { + id: 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES + 1), + data: 'output', + deliveryToken: 'token' + }, + deliver + ) + credit.ingest( + { + id: 'pty-1', + data: 'output', + deliveryToken: 'x'.repeat(MAX_SSH_PTY_DELIVERY_TOKEN_BYTES + 1) + }, + deliver + ) + + expect(deliver).not.toHaveBeenCalled() + expect(retainedStates(credit)).toHaveLength(0) + }) + + it('caps unique-id credit state churn and returns evicted credit', () => { + const notifyAck = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(notifyAck) + for (let index = 0; index < MAX_SSH_PTY_CREDIT_STATES + 50; index += 1) { + credit.ingest({ id: `pty-${index}`, data: 'x', deliveryToken: `token-${index}` }, () => {}) + } + + const states = retainedStates(credit) + expect(states.size).toBe(MAX_SSH_PTY_CREDIT_STATES) + expect(states.has('pty-0')).toBe(false) + expect(states.has(`pty-${MAX_SSH_PTY_CREDIT_STATES + 49}`)).toBe(true) + expect(notifyAck).toHaveBeenCalledWith({ + id: 'pty-0', + charCount: 1, + deliveryToken: 'token-0' + }) + }) + + it('caps aggregate credit-state id bytes', () => { + const credit = new SshPtyOutputDeliveryCredit(vi.fn()) + const suffix = 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES - 16) + for (let index = 0; index < MAX_SSH_PTY_CREDIT_STATES; index += 1) { + credit.ingest( + { id: `pty-${index}:${suffix}`, data: 'x', deliveryToken: `token-${index}` }, + () => {} + ) + } + + const retainedBytes = [...retainedStates(credit).keys()].reduce( + (total, id) => total + Buffer.byteLength(id), + 0 + ) + expect(retainedBytes).toBeLessThanOrEqual(MAX_SSH_PTY_CREDIT_STATE_ID_BYTES) + expect(retainedStates(credit).size).toBeLessThan(MAX_SSH_PTY_CREDIT_STATES) + }) + + it('caps retained segments per state and in aggregate', () => { + const credit = new SshPtyOutputDeliveryCredit(vi.fn()) + const perStateTotal = MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE + 50 + for (let index = 0; index < perStateTotal; index += 1) { + credit.ingest({ id: 'pty-one', data: 'x', deliveryToken: 'token-one' }, () => {}) + } + expect(retainedStates(credit).get('pty-one')?.segments).toHaveLength( + MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE + ) + + for (let state = 0; state < 8; state += 1) { + for (let segment = 0; segment < MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE; segment += 1) { + credit.ingest({ id: `pty-${state}`, data: 'x', deliveryToken: `token-${state}` }, () => {}) + } + } + + const retained = [...retainedStates(credit).values()].reduce( + (total, state) => total + state.segments.length, + 0 + ) + expect(retained).toBe(MAX_SSH_PTY_CREDIT_SEGMENTS) + }) + + it('returns unretained direct credit when state churn evicts its owner', () => { + const notifyAck = vi.fn() + const credit = new SshPtyOutputDeliveryCredit(notifyAck) + for (let index = 0; index <= MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE; index += 1) { + credit.ingest({ id: 'pty-direct', data: 'x', deliveryToken: 'token-direct' }, () => {}) + } + for (let index = 0; index < MAX_SSH_PTY_CREDIT_STATES; index += 1) { + credit.ingest({ id: `pty-${index}`, data: 'x', deliveryToken: `token-${index}` }, () => {}) + } + + expect(notifyAck).toHaveBeenCalledWith({ + id: 'pty-direct', + charCount: MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE + 1, + deliveryToken: 'token-direct' + }) + }) +}) diff --git a/src/main/providers/ssh-pty-output-delivery-credit.ts b/src/main/providers/ssh-pty-output-delivery-credit.ts new file mode 100644 index 00000000000..1c0c40ad007 --- /dev/null +++ b/src/main/providers/ssh-pty-output-delivery-credit.ts @@ -0,0 +1,280 @@ +import type { PtyDataUpstreamCredit } from './pty-provider-events' +import { + admittedSshRelayPtyIdBytes, + isAdmittedSshDeliveryToken, + isAdmittedSshRelayPtyId +} from './ssh-pty-wire-admission' + +export const MAX_SSH_PTY_CREDIT_STATES = 256 +export const MAX_SSH_PTY_CREDIT_STATE_ID_BYTES = 512 * 1024 +export const MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE = 4096 +export const MAX_SSH_PTY_CREDIT_SEGMENTS = 16_384 + +export type SshPtyOutputDelivery = { + relayId: string + data: string + rawLength?: number + transformed: boolean + seq?: number + upstreamCredit?: PtyDataUpstreamCredit +} + +type AckParams = { + id: string + charCount: number + deliveryToken?: string +} + +export class SshPtyOutputDeliveryCredit { + private readonly states = new Map() + private disposed = false + private retainedStateIdBytes = 0 + private retainedSegments = 0 + + constructor(private readonly notifyAck: (params: AckParams) => void) {} + + ingest(params: Record, deliver: (output: SshPtyOutputDelivery) => void): void { + const relayId = params.id + const data = params.data + if (!isAdmittedSshRelayPtyId(relayId) || typeof data !== 'string') { + return + } + if (params.deliveryToken !== undefined && !isAdmittedSshDeliveryToken(params.deliveryToken)) { + return + } + const deliveryToken = params.deliveryToken as string | undefined + const rawLength = + typeof params.rawLength === 'number' && + Number.isSafeInteger(params.rawLength) && + params.rawLength >= 0 + ? params.rawLength + : undefined + const seq = + typeof params.seq === 'number' && Number.isSafeInteger(params.seq) && params.seq >= 0 + ? params.seq + : undefined + const charCount = rawLength ?? data.length + const upstreamCredit = this.createCredit(relayId, charCount, deliveryToken) + try { + deliver({ + relayId, + data, + ...(rawLength === undefined ? {} : { rawLength }), + transformed: params.transformed === true, + ...(seq === undefined ? {} : { seq }), + ...(upstreamCredit ? { upstreamCredit } : {}) + }) + } catch (error) { + upstreamCredit?.acknowledge(charCount) + throw error + } + } + + acknowledgeLegacy(relayId: string, charCount: number): void { + if ( + !isAdmittedSshRelayPtyId(relayId) || + !Number.isFinite(charCount) || + charCount <= 0 || + this.disposed + ) { + return + } + const state = this.states.get(relayId) + if (!state) { + this.notifyAck({ id: relayId, charCount: Math.floor(charCount) }) + return + } + let remaining = Math.floor(charCount) + while (remaining > 0 && state.segments.length > 0) { + const segment = state.segments[0] + const acknowledged = Math.min(remaining, segment.remainingChars) + this.acknowledgeSegment(state, segment, acknowledged) + remaining -= acknowledged + } + if (remaining > 0 && !state.tokenized) { + this.notifyAck({ id: relayId, charCount: remaining }) + } + } + + release(relayId: string): void { + const state = this.states.get(relayId) + if (!state) { + return + } + this.removeState(state, false) + } + + dispose(): void { + this.disposed = true + for (const state of this.states.values()) { + state.active = false + this.clearSegments(state) + } + this.states.clear() + this.retainedStateIdBytes = 0 + } + + private createCredit( + relayId: string, + charCount: number, + deliveryToken: string | undefined + ): PtyDataUpstreamCredit | undefined { + if (this.disposed || charCount <= 0 || !deliveryToken) { + return undefined + } + let existing = this.states.get(relayId) + // Why: pty.attach rotates the relay token; stale renderer credit from the prior + // generation must not pin every later acknowledged segment behind it. + if (existing && existing.deliveryToken !== deliveryToken) { + this.removeState(existing, false) + existing = undefined + } + const state = + existing?.active === true + ? existing + : { + relayId, + idBytes: admittedSshRelayPtyIdBytes(relayId) ?? 0, + active: true, + tokenized: true, + deliveryToken, + segments: [], + directOutstandingChars: 0 + } + if (!existing) { + this.retainedStateIdBytes += state.idBytes + } else { + this.states.delete(relayId) + } + this.states.set(relayId, state) + this.capStates() + if ( + state.segments.length >= MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE || + this.retainedSegments >= MAX_SSH_PTY_CREDIT_SEGMENTS + ) { + return this.createDirectCredit(state, charCount) + } + const segment: PtyCreditSegment = { + deliveryToken, + remainingChars: charCount + } + state.segments.push(segment) + this.retainedSegments++ + return { + charCount, + acknowledge: (requestedChars) => { + if (!Number.isFinite(requestedChars) || requestedChars <= 0) { + return + } + this.acknowledgeSegment(state, segment, Math.floor(requestedChars)) + } + } + } + + private acknowledgeSegment( + state: PtyCreditState, + segment: PtyCreditSegment, + requestedChars: number + ): void { + if (this.disposed || !state.active || requestedChars <= 0 || segment.remainingChars <= 0) { + return + } + const charCount = Math.min(requestedChars, segment.remainingChars) + segment.remainingChars -= charCount + this.notifyAck({ + id: state.relayId, + charCount, + ...(segment.deliveryToken ? { deliveryToken: segment.deliveryToken } : {}) + }) + while (state.segments[0]?.remainingChars === 0) { + state.segments.shift() + this.retainedSegments-- + } + } + + private createDirectCredit(state: PtyCreditState, charCount: number): PtyDataUpstreamCredit { + let remainingChars = charCount + state.directOutstandingChars += charCount + return { + charCount, + acknowledge: (requestedChars) => { + if ( + this.disposed || + !state.active || + state.deliveryToken.length === 0 || + !Number.isFinite(requestedChars) || + requestedChars <= 0 || + remainingChars <= 0 + ) { + return + } + const acknowledged = Math.min(Math.floor(requestedChars), remainingChars) + remainingChars -= acknowledged + state.directOutstandingChars -= acknowledged + this.notifyAck({ + id: state.relayId, + charCount: acknowledged, + deliveryToken: state.deliveryToken + }) + } + } + } + + private capStates(): void { + while ( + this.states.size > MAX_SSH_PTY_CREDIT_STATES || + this.retainedStateIdBytes > MAX_SSH_PTY_CREDIT_STATE_ID_BYTES + ) { + const oldest = this.states.values().next().value as PtyCreditState | undefined + if (!oldest) { + return + } + this.removeState(oldest, true) + } + } + + private removeState(state: PtyCreditState, returnOutstanding: boolean): void { + state.active = false + if (returnOutstanding) { + const outstandingChars = + state.directOutstandingChars + + state.segments.reduce((total, segment) => total + segment.remainingChars, 0) + if (outstandingChars > 0) { + try { + this.notifyAck({ + id: state.relayId, + charCount: outstandingChars, + deliveryToken: state.deliveryToken + }) + } catch { + // Best effort: eviction must still enforce the local memory bound. + } + } + } + state.directOutstandingChars = 0 + this.clearSegments(state) + if (this.states.delete(state.relayId)) { + this.retainedStateIdBytes -= state.idBytes + } + } + + private clearSegments(state: PtyCreditState): void { + this.retainedSegments -= state.segments.length + state.segments.length = 0 + } +} + +type PtyCreditSegment = { + deliveryToken?: string + remainingChars: number +} + +type PtyCreditState = { + relayId: string + idBytes: number + active: boolean + tokenized: boolean + deliveryToken: string + segments: PtyCreditSegment[] + directOutstandingChars: number +} diff --git a/src/main/providers/ssh-pty-provider-contract.ts b/src/main/providers/ssh-pty-provider-contract.ts index 9043133b03c..34d46a9148d 100644 --- a/src/main/providers/ssh-pty-provider-contract.ts +++ b/src/main/providers/ssh-pty-provider-contract.ts @@ -1,4 +1,5 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { PtyDataEvent } from './pty-provider-events' export type RemoteCliBridgeEnv = { binDir: string @@ -8,13 +9,7 @@ export type RemoteCliBridgeEnv = { pathDelimiter?: ':' | ';' } -export type SshPtyDataCallback = (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number -}) => void +export type SshPtyDataCallback = (payload: PtyDataEvent) => void export type SshPtyReplayCallback = (payload: { id: string; data: string }) => void export type SshPtyExitCallback = (payload: { id: string diff --git a/src/main/providers/ssh-pty-provider-exit-race.test.ts b/src/main/providers/ssh-pty-provider-exit-race.test.ts index 12616e4d133..bca158029eb 100644 --- a/src/main/providers/ssh-pty-provider-exit-race.test.ts +++ b/src/main/providers/ssh-pty-provider-exit-race.test.ts @@ -76,3 +76,34 @@ it('rejects an SSH reattach whose matching exit shares the attach reply batch', isReattach: true }) }) + +it('does not treat a malformed exit incarnation as a wildcard spawn race', async () => { + const mux = { + request: vi.fn(), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } + const provider = new SshPtyProvider('conn-1', mux as never) + const exitListener = vi.fn() + provider.onExit(exitListener) + mux.request.mockImplementation(async (method: string) => { + if (method === 'pty.spawn') { + const notify = mux.onNotification.mock.calls[0]?.[0] + notify?.('pty.exit', { + id: 'pty-raced', + code: 0, + incarnationId: 'x'.repeat(129) + }) + return { id: 'pty-raced', incarnationId: 'incarnation-current' } + } + return undefined + }) + + await expect(provider.spawn({ cols: 80, rows: 24 })).resolves.toMatchObject({ + id: 'ssh:conn-1@@pty-raced', + incarnationId: 'incarnation-current' + }) + expect(exitListener).not.toHaveBeenCalled() +}) diff --git a/src/main/providers/ssh-pty-provider-notifications.ts b/src/main/providers/ssh-pty-provider-notifications.ts new file mode 100644 index 00000000000..7d6524f0818 --- /dev/null +++ b/src/main/providers/ssh-pty-provider-notifications.ts @@ -0,0 +1,124 @@ +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { + SshPtyDataCallback, + SshPtyExitCallback, + SshPtyReplayCallback +} from './ssh-pty-provider-contract' +import { SshPtyOutputDeliveryCredit } from './ssh-pty-output-delivery-credit' +import { isAdmittedSshRelayPtyId } from './ssh-pty-wire-admission' + +export class SshPtyProviderNotifications { + private readonly dataListeners = new Set() + private readonly replayListeners = new Set() + private readonly exitListeners = new Set() + private readonly outputDeliveryCredit: SshPtyOutputDeliveryCredit + private unsubscribe: (() => void) | null + + constructor( + mux: SshChannelMultiplexer, + private readonly toAppPtyId: (relayId: string) => string, + recordNotification: (relayId: string) => void, + recordExit: (relayId: string, incarnationId: unknown) => void + ) { + this.outputDeliveryCredit = new SshPtyOutputDeliveryCredit((params) => { + mux.notify('pty.ackData', params) + }) + this.unsubscribe = mux.onNotification((method, params) => { + const relayId = params.id + if (!isAdmittedSshRelayPtyId(relayId)) { + return + } + switch (method) { + case 'pty.data': + this.emitData(params, recordNotification) + break + case 'pty.replay': { + if (typeof params.data !== 'string') { + return + } + recordNotification(relayId) + for (const listener of this.replayListeners) { + listener({ + id: this.toAppPtyId(relayId), + data: params.data + }) + } + break + } + case 'pty.exit': { + const code = params.code + const incarnationId = params.incarnationId + if ( + typeof code !== 'number' || + !Number.isSafeInteger(code) || + (incarnationId !== undefined && !isPtyIncarnationId(incarnationId)) + ) { + return + } + this.outputDeliveryCredit.release(relayId) + recordExit(relayId, incarnationId) + for (const listener of this.exitListeners) { + listener({ + id: this.toAppPtyId(relayId), + code, + ...(isPtyIncarnationId(incarnationId) ? { incarnationId } : {}) + }) + } + break + } + } + }) + } + + private emitData( + params: Record, + recordNotification: (relayId: string) => void + ): void { + this.outputDeliveryCredit.ingest(params, (output) => { + recordNotification(output.relayId) + if (this.dataListeners.size === 0) { + output.upstreamCredit?.acknowledge(output.upstreamCredit.charCount) + return + } + for (const listener of this.dataListeners) { + listener({ + id: this.toAppPtyId(output.relayId), + data: output.data, + ...(output.rawLength === undefined ? {} : { sequenceChars: output.rawLength }), + ...(output.transformed ? { transformed: true } : {}), + ...(output.seq === undefined ? {} : { seq: output.seq }), + ...(output.upstreamCredit ? { upstreamCredit: output.upstreamCredit } : {}) + }) + } + }) + } + + acknowledgeLegacy(relayId: string, charCount: number): void { + this.outputDeliveryCredit.acknowledgeLegacy(relayId, charCount) + } + + onData(callback: SshPtyDataCallback): () => void { + this.dataListeners.add(callback) + return () => this.dataListeners.delete(callback) + } + + onReplay(callback: SshPtyReplayCallback): () => void { + this.replayListeners.add(callback) + return () => this.replayListeners.delete(callback) + } + + onExit(callback: SshPtyExitCallback): () => void { + this.exitListeners.add(callback) + return () => this.exitListeners.delete(callback) + } + + dispose(): void { + this.unsubscribe?.() + this.unsubscribe = null + this.dataListeners.clear() + this.replayListeners.clear() + this.exitListeners.clear() + this.outputDeliveryCredit.dispose() + } +} diff --git a/src/main/providers/ssh-pty-provider-process-events.test.ts b/src/main/providers/ssh-pty-provider-process-events.test.ts index 2d033aa40d3..4a2cf30c6b0 100644 --- a/src/main/providers/ssh-pty-provider-process-events.test.ts +++ b/src/main/providers/ssh-pty-provider-process-events.test.ts @@ -1,5 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SshPtyProvider } from './ssh-pty-provider' +import { + MAX_SSH_PTY_PROCESS_CWD_BYTES, + MAX_SSH_PTY_PROCESS_LIST_BYTES, + MAX_SSH_PTY_PROCESS_LIST_ENTRIES, + MAX_SSH_PTY_PROCESS_LIST_OWNERS, + MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY, + MAX_SSH_PTY_PROCESS_TITLE_BYTES +} from './ssh-agent-session-process-list' +import { MAX_SSH_RELAY_PTY_ID_BYTES } from './ssh-pty-wire-admission' type MockMultiplexer = { request: ReturnType @@ -89,6 +98,71 @@ describe('SshPtyProvider process listings and events', () => { ]) }) + it('drops unknown process and owner fields instead of retaining remote payloads', async () => { + const unknownPayload = 'x'.repeat(1024 * 1024) + mux.request.mockResolvedValue([ + { + id: 'pty-1', + incarnationId: 'incarnation-1', + cwd: '/home', + title: 'codex', + unknownPayload, + agentSessionOwners: [ + { + claim: { + digestVersion: 1, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex', + unknownPayload + }, + generation: 'generation-1', + phase: 'live', + ptyId: 'pty-1', + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed', + unknownPayload + }, + unknownPayload + } + ] + } + ]) + + await expect(provider.listProcesses()).resolves.toEqual([ + { + id: scopedPty1, + incarnationId: 'incarnation-1', + cwd: '/home', + title: 'codex', + agentSessionOwners: [ + { + claim: { + digestVersion: 1, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' + }, + generation: 'generation-1', + phase: 'live', + ptyId: scopedPty1, + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + } + ] + } + ]) + }) + it('rejects recovered claimed owners without PTY incarnation proof', async () => { mux.request.mockResolvedValue([ { @@ -121,6 +195,126 @@ describe('SshPtyProvider process listings and events', () => { await expect(provider.listProcesses()).rejects.toThrow('agent_session_ownership_unknown') }) + it('rejects a non-array process list before mapping ids', async () => { + mux.request.mockResolvedValue({ id: 'pty-1' }) + + await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') + }) + + it('rejects an oversized process list before mapping ids', async () => { + const id = { + toString: vi.fn(() => 'pty-never-map') + } + mux.request.mockResolvedValue( + Array.from({ length: MAX_SSH_PTY_PROCESS_LIST_ENTRIES + 1 }, () => ({ + id, + cwd: '/home', + title: 'shell' + })) + ) + + await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') + expect(id.toString).not.toHaveBeenCalled() + }) + + it('rejects oversized owner arrays before mapping owners', async () => { + mux.request.mockResolvedValue([ + { + id: 'pty-1', + cwd: '/home', + title: 'shell', + agentSessionOwners: Array.from( + { length: MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY + 1 }, + () => ({}) + ) + } + ]) + + await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') + }) + + it('rejects owner amplification spread across otherwise valid sessions', async () => { + const owner = { + claim: { + digestVersion: 1, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' + }, + generation: 'generation-1', + phase: 'live', + ptyId: 'pty-1', + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + } + const ownersPerSession = MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY + const sessionCount = Math.floor(MAX_SSH_PTY_PROCESS_LIST_OWNERS / ownersPerSession) + 1 + mux.request.mockResolvedValue( + Array.from({ length: sessionCount }, (_, sessionIndex) => { + const id = `pty-${sessionIndex}` + return { + id, + incarnationId: 'incarnation-1', + cwd: '/home', + title: 'shell', + agentSessionOwners: Array.from({ length: ownersPerSession }, (_, ownerIndex) => ({ + ...owner, + generation: `generation-${sessionIndex}-${ownerIndex}`, + ptyId: id + })) + } + }) + ) + + await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') + }) + + it.each([ + { + field: 'id', + value: 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES + 1) + }, + { + field: 'cwd', + value: 'x'.repeat(MAX_SSH_PTY_PROCESS_CWD_BYTES + 1) + }, + { + field: 'title', + value: 'x'.repeat(MAX_SSH_PTY_PROCESS_TITLE_BYTES + 1) + } + ])('rejects an oversized $field before mapping', async ({ field, value }) => { + mux.request.mockResolvedValue([{ id: 'pty-1', cwd: '/home', title: 'shell', [field]: value }]) + + await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') + }) + + it('rejects process-list strings above the aggregate byte budget', async () => { + const cwd = 'x'.repeat(MAX_SSH_PTY_PROCESS_CWD_BYTES) + const count = Math.floor(MAX_SSH_PTY_PROCESS_LIST_BYTES / Buffer.byteLength(cwd)) + 1 + mux.request.mockResolvedValue( + Array.from({ length: count }, (_, index) => ({ + id: `pty-${index}`, + cwd, + title: 'shell' + })) + ) + + await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') + }) + + it('rejects an oversized spawn id before retention', async () => { + const oversized = 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES + 1) + mux.request.mockResolvedValue({ id: oversized }) + + await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow('invalid_ssh_pty_id') + expect(provider.hasPty(`ssh:conn-1@@${oversized}`)).toBe(false) + }) + it('forwards data, replay, and incarnation-aware exit notifications', () => { const dataHandler = vi.fn() const replayHandler = vi.fn() @@ -133,7 +327,9 @@ describe('SshPtyProvider process listings and events', () => { notify('pty.data', { id: 'pty-1', data: 'output' }) notify('pty.data', { id: 'pty-1', data: '', rawLength: 9, seq: 9, transformed: true }) notify('pty.replay', { id: 'pty-1', data: 'buffered output' }) + expect(provider.hasPty(scopedPty1)).toBe(true) notify('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) + expect(provider.hasPty(scopedPty1)).toBe(false) expect(dataHandler).toHaveBeenNthCalledWith(1, { id: scopedPty1, data: 'output' }) expect(dataHandler).toHaveBeenNthCalledWith(2, { @@ -151,6 +347,51 @@ describe('SshPtyProvider process listings and events', () => { }) }) + it('drops malformed notification ids before routing or retention', () => { + const dataHandler = vi.fn() + const replayHandler = vi.fn() + const exitHandler = vi.fn() + provider.onData(dataHandler) + provider.onReplay(replayHandler) + provider.onExit(exitHandler) + const notify = mux.onNotification.mock.calls[0][0] + const oversized = 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES + 1) + + notify('pty.data', { id: 42, data: 'output', deliveryToken: 'token' }) + notify('pty.replay', { id: oversized, data: 'replay' }) + notify('pty.exit', { id: oversized, code: 0 }) + + expect(dataHandler).not.toHaveBeenCalled() + expect(replayHandler).not.toHaveBeenCalled() + expect(exitHandler).not.toHaveBeenCalled() + expect(provider.hasPty(`ssh:conn-1@@${oversized}`)).toBe(false) + expect(mux.notify).not.toHaveBeenCalled() + }) + + it('drops malformed notification payloads before liveness or exit-race mutation', () => { + const dataHandler = vi.fn() + const replayHandler = vi.fn() + const exitHandler = vi.fn() + provider.onData(dataHandler) + provider.onReplay(replayHandler) + provider.onExit(exitHandler) + const notify = mux.onNotification.mock.calls[0][0] + + notify('pty.data', { id: 'pty-bad-data', data: 42, deliveryToken: 'token' }) + notify('pty.replay', { id: 'pty-bad-replay', data: { nested: 'output' } }) + notify('pty.exit', { id: 'pty-bad-code', code: '0', incarnationId: 'incarnation-1' }) + notify('pty.exit', { id: 'pty-bad-incarnation', code: 0, incarnationId: 'x'.repeat(129) }) + + expect(dataHandler).not.toHaveBeenCalled() + expect(replayHandler).not.toHaveBeenCalled() + expect(exitHandler).not.toHaveBeenCalled() + expect(provider.hasPty('ssh:conn-1@@pty-bad-data')).toBe(false) + expect(provider.hasPty('ssh:conn-1@@pty-bad-replay')).toBe(false) + expect(provider.hasPty('ssh:conn-1@@pty-bad-code')).toBe(false) + expect(provider.hasPty('ssh:conn-1@@pty-bad-incarnation')).toBe(false) + expect(mux.notify).not.toHaveBeenCalled() + }) + it('supports listener removal, fanout, and connection namespaces', () => { const removed = vi.fn() const first = vi.fn() diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index f204369c9ef..c83ba43908a 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -32,6 +32,16 @@ describe('SshPtyProvider', () => { provider = new SshPtyProvider('conn-1', mux as never) }) + function emitRelayNotification(method: string, params: Record): void { + const callback = mux.onNotification.mock.calls[0]?.[0] as + | ((method: string, params: Record) => void) + | undefined + if (!callback) { + throw new Error('Notification listener was not registered') + } + callback(method, params) + } + it('returns the connectionId', () => { expect(provider.getConnectionId()).toBe('conn-1') }) @@ -821,6 +831,47 @@ describe('SshPtyProvider', () => { expect(mux.notify).toHaveBeenCalledWith('pty.ackData', { id: 'pty-1', charCount: 1024 }) }) + it('defers tokenized relay output credit until downstream acknowledgement', () => { + const events: Parameters[0]>[0][] = [] + provider.onData((event) => { + events.push(event) + }) + + emitRelayNotification('pty.data', { + id: 'pty-1', + data: 'clean', + rawLength: 12, + deliveryToken: 'delivery-1' + }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ id: scopedPty1, data: 'clean', sequenceChars: 12 }) + expect(mux.notify).not.toHaveBeenCalled() + + events[0]?.upstreamCredit?.acknowledge(12) + + expect(mux.notify).toHaveBeenCalledOnce() + expect(mux.notify).toHaveBeenCalledWith('pty.ackData', { + id: 'pty-1', + charCount: 12, + deliveryToken: 'delivery-1' + }) + }) + + it('restores legacy acknowledgements after tokenized PTY exit', () => { + emitRelayNotification('pty.data', { + id: 'pty-1', + data: 'output', + deliveryToken: 'delivery-3' + }) + emitRelayNotification('pty.exit', { id: 'pty-1', code: 0 }) + mux.notify.mockClear() + + provider.acknowledgeDataEvent(scopedPty1, 6) + + expect(mux.notify).toHaveBeenCalledWith('pty.ackData', { id: 'pty-1', charCount: 6 }) + }) + it('hasChildProcesses sends request and returns result', async () => { mux.request.mockResolvedValue(true) const result = await provider.hasChildProcesses(scopedPty1) @@ -835,6 +886,18 @@ describe('SshPtyProvider', () => { expect(mux.request).toHaveBeenCalledWith('pty.getForegroundProcess', { id: 'pty-1' }) }) + it('preserves unavailable process inspection', async () => { + const inspection = { + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true as const + } + mux.request.mockResolvedValue(inspection) + + await expect(provider.inspectProcess(scopedPty1)).resolves.toEqual(inspection) + expect(mux.request).toHaveBeenCalledWith('pty.inspectProcess', { id: 'pty-1' }) + }) + it('serializes scoped app ids using raw relay ids', async () => { mux.request.mockResolvedValue('serialized') diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 6dc0cb39a1d..9173fd5b9ca 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -8,7 +8,6 @@ import type { SshPtyExitCallback, SshPtyReplayCallback } from './ssh-pty-provider-contract' -import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation' import { assertSshAgentSessionCreateResult, @@ -23,6 +22,10 @@ import { import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request' import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' +import { SshPtyProviderNotifications } from './ssh-pty-provider-notifications' +import { SshPtyLiveRoster } from './ssh-pty-live-roster' +import { isAdmittedSshRelayPtyId } from './ssh-pty-wire-admission' +import type { PtyProcessInspection } from './pty-process-inspection' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { @@ -33,15 +36,11 @@ function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: numbe export class SshPtyProvider implements IPtyProvider { private mux: SshChannelMultiplexer private connectionId: string - private dataListeners = new Set() - private replayListeners = new Set() - private exitListeners = new Set() - private livePtyIds = new Set() - // Why: stale notification callbacks must not outlive a disconnected provider. - private unsubscribeNotifications: (() => void) | null = null + private readonly livePtys = new SshPtyLiveRoster() readonly getAppliedSize: NonNullable private readonly agentSessionCapabilities: SshAgentSessionCapabilities private spawnExitRaces = new SshPtySpawnExitRaceTracker() + private readonly notifications: SshPtyProviderNotifications constructor( connectionId: string, @@ -52,28 +51,20 @@ export class SshPtyProvider implements IPtyProvider { this.mux = mux this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux) this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId) - - this.unsubscribeNotifications = subscribeSshPtyNotifications({ + this.notifications = new SshPtyProviderNotifications( mux, - toAppPtyId: (id) => this.toAppPtyId(id), - dataListeners: this.dataListeners, - replayListeners: this.replayListeners, - exitListeners: this.exitListeners, - livePtyIds: this.livePtyIds, - recordExit: (relayPtyId, incarnationId) => - this.spawnExitRaces.recordExit(relayPtyId, incarnationId) - }) + (relayId) => this.toAppPtyId(relayId), + (relayId) => this.livePtys.recordNotification(this.toAppPtyId(relayId)), + (relayId, incarnationId) => { + this.spawnExitRaces.recordExit(relayId, incarnationId) + this.livePtys.recordExit(this.toAppPtyId(relayId)) + } + ) } dispose(): void { - if (this.unsubscribeNotifications) { - this.unsubscribeNotifications() - this.unsubscribeNotifications = null - } - this.dataListeners.clear() - this.replayListeners.clear() - this.exitListeners.clear() - this.livePtyIds.clear() + this.livePtys.clear() + this.notifications.dispose() } getConnectionId = (): string => this.connectionId @@ -107,7 +98,7 @@ export class SshPtyProvider implements IPtyProvider { options: opts, exitRaceTracker: this.spawnExitRaces }) - this.livePtyIds.add(result.id) + this.livePtys.recordSpawn(result.id) return result } @@ -137,6 +128,9 @@ export class SshPtyProvider implements IPtyProvider { assertSshAgentSessionCreateResult(result) } const spawnResult = result as PtySpawnResult + if (!isAdmittedSshRelayPtyId(spawnResult.id)) { + throw new Error('invalid_ssh_pty_id') + } if (this.spawnExitRaces.didMatchingExitArrive(operation, spawnResult)) { // Why: relay notification can share the response batch; no controller registration may follow. throw Object.assign(new Error('agent_session_exited_during_start'), { @@ -160,7 +154,7 @@ export class SshPtyProvider implements IPtyProvider { } } const id = this.toAppPtyId(spawnResult.id) - this.livePtyIds.add(id) + this.livePtys.recordSpawn(id) return { ...spawnResult, id, @@ -239,7 +233,7 @@ export class SshPtyProvider implements IPtyProvider { }, relayTimeoutOptions(opts.deadlineMs) ) - this.livePtyIds.delete(id) + this.livePtys.recordExit(id) } async sendSignal(id: string, signal: string): Promise { @@ -268,7 +262,8 @@ export class SshPtyProvider implements IPtyProvider { } acknowledgeDataEvent(id: string, charCount: number): void { - this.mux.notify('pty.ackData', { id: this.toRelayPtyId(id), charCount }) + const relayId = this.toRelayPtyId(id) + this.notifications.acknowledgeLegacy(relayId, charCount) } async hasChildProcesses(id: string): Promise { @@ -281,12 +276,10 @@ export class SshPtyProvider implements IPtyProvider { return result as string | null } - async inspectProcess( - id: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + async inspectProcess(id: string): Promise { return (await this.mux.request('pty.inspectProcess', { id: this.toRelayPtyId(id) - })) as { foregroundProcess: string | null; hasChildProcesses: boolean } + })) as PtyProcessInspection } async serialize(ids: string[]): Promise { @@ -301,20 +294,22 @@ export class SshPtyProvider implements IPtyProvider { } async listProcesses(opts?: { deadlineMs?: number }): Promise { + const listing = this.livePtys.beginListing() const result = await this.mux.request( 'pty.listProcesses', undefined, relayTimeoutOptions(opts?.deadlineMs) ) - const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) - for (const process of processes) { - this.livePtyIds.add(process.id) - } + const processes = mapSshPtyProcessList(result, (id) => this.toAppPtyId(id)) + this.livePtys.reconcileListing( + listing, + processes.map((process) => process.id) + ) return processes } hasPty(id: string): boolean { - return this.livePtyIds.has(id) + return this.livePtys.has(id) } async getDefaultShell(): Promise { @@ -328,17 +323,14 @@ export class SshPtyProvider implements IPtyProvider { } onData(callback: SshPtyDataCallback): () => void { - this.dataListeners.add(callback) - return () => this.dataListeners.delete(callback) + return this.notifications.onData(callback) } onReplay(callback: SshPtyReplayCallback): () => void { - this.replayListeners.add(callback) - return () => this.replayListeners.delete(callback) + return this.notifications.onReplay(callback) } onExit(callback: SshPtyExitCallback): () => void { - this.exitListeners.add(callback) - return () => this.exitListeners.delete(callback) + return this.notifications.onExit(callback) } } diff --git a/src/main/providers/ssh-pty-spawn-exit-race.test.ts b/src/main/providers/ssh-pty-spawn-exit-race.test.ts new file mode 100644 index 00000000000..ceace5c6aba --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-exit-race.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_PENDING_SSH_PTY_SPAWN_EXIT_OPERATIONS, + MAX_SSH_PTY_SPAWN_EXIT_BYTES_PER_OPERATION, + MAX_SSH_PTY_SPAWN_EXIT_EVENTS_PER_OPERATION, + SshPtySpawnExitRaceTracker +} from './ssh-pty-spawn-exit-race' +import { MAX_SSH_RELAY_PTY_ID_BYTES } from './ssh-pty-wire-admission' + +describe('SshPtySpawnExitRaceTracker', () => { + it('matches the same PTY incarnation without fencing a replacement', () => { + const tracker = new SshPtySpawnExitRaceTracker() + const operation = tracker.begin() + + tracker.recordExit('pty-1', 'incarnation-old') + + expect( + tracker.didMatchingExitArrive(operation, { + id: 'pty-1', + incarnationId: 'incarnation-current' + }) + ).toBe(false) + expect( + tracker.didMatchingExitArrive(operation, { + id: 'pty-1', + incarnationId: 'incarnation-old' + }) + ).toBe(true) + }) + + it('does not turn malformed incarnation metadata into a wildcard exit', () => { + const tracker = new SshPtySpawnExitRaceTracker() + const operation = tracker.begin() + + tracker.recordExit('pty-1', 'x'.repeat(129)) + + expect( + tracker.didMatchingExitArrive(operation, { + id: 'pty-1', + incarnationId: 'incarnation-current' + }) + ).toBe(false) + }) + + it('supports all 50 relay sessions without overflowing', () => { + const tracker = new SshPtySpawnExitRaceTracker() + const operation = tracker.begin() + for (let index = 0; index < 50; index += 1) { + tracker.recordExit(`pty-${index}`, `incarnation-${index}`) + } + + expect( + tracker.didMatchingExitArrive(operation, { + id: 'pty-49', + incarnationId: 'incarnation-49' + }) + ).toBe(true) + expect(Reflect.get(operation, 'overflowed')).toBe(false) + }) + + it('fails closed and releases retained exits when the count cap is exceeded', () => { + const tracker = new SshPtySpawnExitRaceTracker() + const operation = tracker.begin() + for (let index = 0; index <= MAX_SSH_PTY_SPAWN_EXIT_EVENTS_PER_OPERATION; index += 1) { + tracker.recordExit(`pty-${index}`, `incarnation-${index}`) + } + + expect(Reflect.get(operation, 'overflowed')).toBe(true) + expect(Reflect.get(operation, 'exits')).toHaveLength(0) + expect( + tracker.didMatchingExitArrive(operation, { + id: 'unseen', + incarnationId: 'unseen-incarnation' + }) + ).toBe(true) + }) + + it('fails closed when retained exit identifiers exceed the byte cap', () => { + const tracker = new SshPtySpawnExitRaceTracker() + const operation = tracker.begin() + const suffix = 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES - 16) + const eventBytes = Buffer.byteLength(`pty-000:${suffix}`) + Buffer.byteLength('💥'.repeat(64)) + const eventsToOverflow = Math.floor(MAX_SSH_PTY_SPAWN_EXIT_BYTES_PER_OPERATION / eventBytes) + 1 + expect(eventsToOverflow).toBeLessThan(MAX_SSH_PTY_SPAWN_EXIT_EVENTS_PER_OPERATION) + + for (let index = 0; index < eventsToOverflow; index += 1) { + tracker.recordExit(`pty-${String(index).padStart(3, '0')}:${suffix}`, '💥'.repeat(64)) + } + + expect(Reflect.get(operation, 'overflowed')).toBe(true) + expect(Reflect.get(operation, 'retainedBytes')).toBe(0) + }) + + it('rejects before dispatch when concurrent operation tracking is saturated', () => { + const tracker = new SshPtySpawnExitRaceTracker() + const admitted = Array.from({ length: MAX_PENDING_SSH_PTY_SPAWN_EXIT_OPERATIONS }, () => + tracker.begin() + ) + + expect(() => tracker.begin()).toThrow('ssh_pty_spawn_exit_tracking_capacity') + + for (const operation of admitted) { + tracker.finish(operation) + } + }) +}) diff --git a/src/main/providers/ssh-pty-spawn-exit-race.ts b/src/main/providers/ssh-pty-spawn-exit-race.ts index 0658e3e23eb..f0f9d677496 100644 --- a/src/main/providers/ssh-pty-spawn-exit-race.ts +++ b/src/main/providers/ssh-pty-spawn-exit-race.ts @@ -1,24 +1,55 @@ import { isPtyIncarnationId, type PtyIncarnationId } from '../../shared/pty-incarnation' +import { admittedSshRelayPtyIdBytes } from './ssh-pty-wire-admission' + +export const MAX_SSH_PTY_SPAWN_EXIT_EVENTS_PER_OPERATION = 128 +export const MAX_SSH_PTY_SPAWN_EXIT_BYTES_PER_OPERATION = 512 * 1024 +export const MAX_PENDING_SSH_PTY_SPAWN_EXIT_OPERATIONS = 128 type PendingSshPtySpawn = { exits: { relayPtyId: string; incarnationId?: PtyIncarnationId }[] + retainedBytes: number + overflowed: boolean } export class SshPtySpawnExitRaceTracker { private pending = new Set() begin(): PendingSshPtySpawn { - const operation = { exits: [] } + if (this.pending.size >= MAX_PENDING_SSH_PTY_SPAWN_EXIT_OPERATIONS) { + throw new Error('ssh_pty_spawn_exit_tracking_capacity') + } + const operation: PendingSshPtySpawn = { + exits: [], + retainedBytes: 0, + overflowed: false + } this.pending.add(operation) return operation } recordExit(relayPtyId: string, incarnationId: unknown): void { + const idBytes = admittedSshRelayPtyIdBytes(relayPtyId) + if (idBytes === null || (incarnationId !== undefined && !isPtyIncarnationId(incarnationId))) { + return + } + const retainedBytes = + idBytes + (isPtyIncarnationId(incarnationId) ? Buffer.byteLength(incarnationId, 'utf8') : 0) for (const operation of this.pending) { + if ( + operation.overflowed || + operation.exits.length >= MAX_SSH_PTY_SPAWN_EXIT_EVENTS_PER_OPERATION || + operation.retainedBytes + retainedBytes > MAX_SSH_PTY_SPAWN_EXIT_BYTES_PER_OPERATION + ) { + operation.overflowed = true + operation.exits.length = 0 + operation.retainedBytes = 0 + continue + } operation.exits.push({ relayPtyId, ...(isPtyIncarnationId(incarnationId) ? { incarnationId } : {}) }) + operation.retainedBytes += retainedBytes } } @@ -26,16 +57,21 @@ export class SshPtySpawnExitRaceTracker { operation: PendingSshPtySpawn, result: { id: string; incarnationId?: PtyIncarnationId } ): boolean { - return operation.exits.some( - (exit) => - exit.relayPtyId === result.id && - (!exit.incarnationId || - !result.incarnationId || - exit.incarnationId === result.incarnationId) + return ( + operation.overflowed || + operation.exits.some( + (exit) => + exit.relayPtyId === result.id && + (!exit.incarnationId || + !result.incarnationId || + exit.incarnationId === result.incarnationId) + ) ) } finish(operation: PendingSshPtySpawn): void { this.pending.delete(operation) + operation.exits.length = 0 + operation.retainedBytes = 0 } } diff --git a/src/main/providers/ssh-pty-wire-admission.ts b/src/main/providers/ssh-pty-wire-admission.ts new file mode 100644 index 00000000000..af7ac167fc9 --- /dev/null +++ b/src/main/providers/ssh-pty-wire-admission.ts @@ -0,0 +1,29 @@ +import { hasUnsafeProviderSessionIdChars } from '../../shared/agent-session-resume' + +export const MAX_SSH_RELAY_PTY_ID_BYTES = 4 * 1024 +export const MAX_SSH_APP_PTY_ID_BYTES = 16 * 1024 +export const MAX_SSH_PTY_DELIVERY_TOKEN_BYTES = 1024 + +function admittedStringBytes(value: unknown, maxBytes: number): number | null { + if (typeof value !== 'string' || value.length === 0 || hasUnsafeProviderSessionIdChars(value)) { + return null + } + const bytes = Buffer.byteLength(value, 'utf8') + return bytes <= maxBytes ? bytes : null +} + +export function admittedSshRelayPtyIdBytes(value: unknown): number | null { + return admittedStringBytes(value, MAX_SSH_RELAY_PTY_ID_BYTES) +} + +export function admittedSshAppPtyIdBytes(value: unknown): number | null { + return admittedStringBytes(value, MAX_SSH_APP_PTY_ID_BYTES) +} + +export function isAdmittedSshRelayPtyId(value: unknown): value is string { + return admittedSshRelayPtyIdBytes(value) !== null +} + +export function isAdmittedSshDeliveryToken(value: unknown): value is string { + return admittedStringBytes(value, MAX_SSH_PTY_DELIVERY_TOKEN_BYTES) !== null +} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 7ebe3de9f17..822f540bba0 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -21,6 +21,7 @@ import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-histo import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' +import type { FilesystemPathListingProvider } from './filesystem-path-listing-provider' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { GitProviderStatusOptions } from './git-provider-status-options' @@ -225,10 +226,13 @@ export type FileReadResult = { isBinary: boolean isImage?: boolean mimeType?: string + imageDimensions?: { width: number; height: number } } -export type IFilesystemProvider = { - readDir(dirPath: string): Promise +type FilesystemDirectoryReadOptions = { maxEntries?: number; maxRetainedBytes?: number } + +export type IFilesystemProvider = FilesystemPathListingProvider & { + readDir(dirPath: string, options?: FilesystemDirectoryReadOptions): Promise readFile(filePath: string): Promise readTerminalArtifact?( filePath: string, @@ -257,10 +261,6 @@ export type IFilesystemProvider = { copy(source: string, destination: string): Promise realpath(filePath: string): Promise search(opts: SearchOptions): Promise - listFiles( - rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } - ): Promise scanWorkspaceSpace?( rootPath: string, options?: { signal?: AbortSignal } diff --git a/src/main/pty-descendant-termination.test.ts b/src/main/pty-descendant-termination.test.ts index e17e186280f..c6941e1cb28 100644 --- a/src/main/pty-descendant-termination.test.ts +++ b/src/main/pty-descendant-termination.test.ts @@ -467,7 +467,7 @@ describe('killWithDescendantSweep', () => { expect(killRoot).toHaveBeenCalledOnce() }) - it('on Windows still taskkills when identity is unknown, keeping orphan cleanup', async () => { + it('on Windows skips taskkill when identity is unknown', async () => { const killWindowsTree = vi.fn(async () => {}) const killRoot = vi.fn() await killWithDescendantSweep(4242, killRoot, { @@ -475,11 +475,11 @@ describe('killWithDescendantSweep', () => { killWindowsTree, verifyTreeKillTarget: async () => 'unknown' }) - expect(killWindowsTree).toHaveBeenCalledWith(4242) + expect(killWindowsTree).not.toHaveBeenCalled() expect(killRoot).toHaveBeenCalledOnce() }) - it('on Windows still taskkills when the identity probe throws', async () => { + it('on Windows skips taskkill when the identity probe throws', async () => { const killWindowsTree = vi.fn(async () => {}) const killRoot = vi.fn() await killWithDescendantSweep(4242, killRoot, { @@ -489,7 +489,7 @@ describe('killWithDescendantSweep', () => { throw new Error('probe exploded') } }) - expect(killWindowsTree).toHaveBeenCalledWith(4242) + expect(killWindowsTree).not.toHaveBeenCalled() expect(killRoot).toHaveBeenCalledOnce() }) diff --git a/src/main/pty-descendant-termination.ts b/src/main/pty-descendant-termination.ts index db1f7638ae1..34ad425b86c 100644 --- a/src/main/pty-descendant-termination.ts +++ b/src/main/pty-descendant-termination.ts @@ -204,7 +204,7 @@ type SnapshotDeps = { * signalled: once the root dies, surviving descendants reparent to pid 1 and * can no longer be found by a ppid walk. Resolves null (never rejects) on * Windows, ps failure, or timeout — callers then degrade to shell-only kill - * on POSIX, or Windows `taskkill /T` via killWithDescendantSweep. + * on POSIX, or identity-gated Windows `taskkill /T` via killWithDescendantSweep. */ export async function captureDescendantSnapshot( rootPid: number, @@ -237,9 +237,9 @@ type KillSweepDeps = SnapshotDeps & /** * Standard agent-session kill sequencing. * - POSIX: snapshot the descendant tree, signal members, then killRoot. - * - Windows: taskkill /T /F walks the ConPTY tree (shell → agent → MCP) so - * worktree teardown is not blocked by orphans holding the cwd handle, but only - * after the OS confirms the root PID is still ours and not a recycled stranger. + * - Windows: taskkill /T /F walks the ConPTY tree only when the identity probe + * returns `own` (and ownsRoot still holds). `unknown`/`foreign`/`absent` skip + * tree kill; killRoot always runs. Detached children may survive probe failure. * Callers must not signal the root before this runs on POSIX — a dead root's * descendants reparent to pid 1 and become unfindable. Snapshot failure * degrades to killRoot alone on POSIX. @@ -260,7 +260,7 @@ export async function killWithDescendantSweep( const verify = deps.verifyTreeKillTarget ?? verifyWindowsTreeKillTarget const target = await verify(rootPid).catch((): WindowsTreeKillTarget => 'unknown') // Re-check ownership: the identity query awaits, so exit can land meanwhile. - if (target !== 'absent' && target !== 'foreign' && (deps.ownsRoot?.() ?? true)) { + if (target === 'own' && (deps.ownsRoot?.() ?? true)) { const killTree = deps.killWindowsTree ?? terminateWindowsProcessTree // Why: taskkill may race an already-exited tree; never block killRoot on that. await killTree(rootPid).catch(() => {}) diff --git a/src/main/pty/config-overlay-mirroring.test.ts b/src/main/pty/config-overlay-mirroring.test.ts new file mode 100644 index 00000000000..764c35bca9e --- /dev/null +++ b/src/main/pty/config-overlay-mirroring.test.ts @@ -0,0 +1,97 @@ +import type { Dirent } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES, + CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES, + CONFIG_OVERLAY_MAX_SOURCE_ENTRIES, + ConfigOverlayCapacityError, + ConfigOverlayEntryBudget, + _configOverlayMirroringInternals +} from './config-overlay-mirroring' + +function fileEntry(name: string): Dirent { + return { + isBlockDevice: () => false, + isCharacterDevice: () => false, + isDirectory: () => false, + isFIFO: () => false, + isFile: () => true, + isSocket: () => false, + isSymbolicLink: () => false, + name + } as unknown as Dirent +} + +describe('config overlay entry budget', () => { + it('accepts the exact entry-count limit and rejects the next streamed entry', () => { + const budget = new ConfigOverlayEntryBudget() + for (let index = 0; index < CONFIG_OVERLAY_MAX_SOURCE_ENTRIES; index += 1) { + budget.reserve('a') + } + + expect(() => budget.reserve('a')).toThrowError( + new ConfigOverlayCapacityError( + 'entries', + CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + 1, + CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + ) + ) + }) + + it('accepts an exact-limit entry name and rejects one byte more', () => { + new ConfigOverlayEntryBudget().reserve('a'.repeat(CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES)) + + expect(() => + new ConfigOverlayEntryBudget().reserve('a'.repeat(CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES + 1)) + ).toThrowError( + new ConfigOverlayCapacityError( + 'entry-name-bytes', + CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES + 1, + CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES + ) + ) + }) + + it('accepts the exact aggregate encoded-name limit and rejects one more name', () => { + const budget = new ConfigOverlayEntryBudget() + const name = 'a'.repeat(4_094) + expect(Buffer.byteLength(JSON.stringify(name), 'utf8')).toBe(4_096) + + for (let index = 0; index < CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES / 4_096; index += 1) { + budget.reserve(name) + } + + expect(() => budget.reserve('a')).toThrowError( + new ConfigOverlayCapacityError( + 'retained-name-bytes', + CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES + 3, + CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES + ) + ) + }) + + it('stops reading immediately after the first over-limit entry', () => { + const entries = Array.from({ length: 10_000 }, (_, index) => fileEntry(`entry-${index}`)) + let reads = 0 + let visits = 0 + const directory = { + readSync() { + reads += 1 + return entries.shift() ?? null + } + } + + expect(() => + _configOverlayMirroringInternals.scanOpenDirectory( + directory, + new ConfigOverlayEntryBudget({ maxEntries: 2 }), + () => { + visits += 1 + } + ) + ).toThrow(ConfigOverlayCapacityError) + expect(reads).toBe(3) + expect(visits).toBe(2) + expect(entries).toHaveLength(9_997) + }) +}) diff --git a/src/main/pty/config-overlay-mirroring.ts b/src/main/pty/config-overlay-mirroring.ts new file mode 100644 index 00000000000..237282ef1be --- /dev/null +++ b/src/main/pty/config-overlay-mirroring.ts @@ -0,0 +1,228 @@ +import { mkdirSync, opendirSync, realpathSync, statSync, type Dirent } from 'node:fs' +import { join } from 'node:path' +import { mirrorEntry } from './overlay-mirror' + +export const CONFIG_OVERLAY_MAX_SOURCE_ENTRIES = 4_096 +export const CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES = 4 * 1_024 +export const CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES = 1_024 * 1_024 + +export type ConfigOverlayCapacityKind = 'entries' | 'entry-name-bytes' | 'retained-name-bytes' + +export type ConfigOverlayLimits = { + maxEntries: number + maxEntryNameBytes: number + maxRetainedNameBytes: number +} + +const DEFAULT_LIMITS: ConfigOverlayLimits = { + maxEntries: CONFIG_OVERLAY_MAX_SOURCE_ENTRIES, + maxEntryNameBytes: CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES, + maxRetainedNameBytes: CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES +} + +export class ConfigOverlayCapacityError extends Error { + constructor( + readonly kind: ConfigOverlayCapacityKind, + readonly observed: number, + readonly limit: number + ) { + super(`Agent config overlay ${kind} exceeded its ${limit} limit (observed ${observed})`) + this.name = 'ConfigOverlayCapacityError' + } +} + +export class ConfigOverlayEntryBudget { + private entryCount = 0 + private retainedNameBytes = 0 + private readonly limits: ConfigOverlayLimits + + constructor(requested?: Partial) { + this.limits = { + maxEntries: resolveLimit(requested?.maxEntries, DEFAULT_LIMITS.maxEntries, 'maxEntries'), + maxEntryNameBytes: resolveLimit( + requested?.maxEntryNameBytes, + DEFAULT_LIMITS.maxEntryNameBytes, + 'maxEntryNameBytes' + ), + maxRetainedNameBytes: resolveLimit( + requested?.maxRetainedNameBytes, + DEFAULT_LIMITS.maxRetainedNameBytes, + 'maxRetainedNameBytes' + ) + } + } + + reserve(name: string): void { + const nextEntryCount = this.entryCount + 1 + if (nextEntryCount > this.limits.maxEntries) { + throw new ConfigOverlayCapacityError('entries', nextEntryCount, this.limits.maxEntries) + } + + const nameBytes = Buffer.byteLength(name, 'utf8') + if (nameBytes > this.limits.maxEntryNameBytes) { + throw new ConfigOverlayCapacityError( + 'entry-name-bytes', + nameBytes, + this.limits.maxEntryNameBytes + ) + } + assertPortableEntryName(name) + + const retainedNameBytes = Buffer.byteLength(JSON.stringify(name), 'utf8') + const nextRetainedNameBytes = this.retainedNameBytes + retainedNameBytes + if (nextRetainedNameBytes > this.limits.maxRetainedNameBytes) { + throw new ConfigOverlayCapacityError( + 'retained-name-bytes', + nextRetainedNameBytes, + this.limits.maxRetainedNameBytes + ) + } + + this.entryCount = nextEntryCount + this.retainedNameBytes = nextRetainedNameBytes + } +} + +export type ConfigOverlayPlan = { + sourceDir: string + topLevelEntryNames: string[] + pluginSourceDir: string | null + pluginEntryNames: string[] +} + +export type AppliedConfigOverlayEntries = { + topLevelEntryNames: string[] + pluginEntryNames: string[] +} + +type ConfigOverlayPlanOptions = { + reservedPluginFile: string + reservedTopLevelEntryNames?: ReadonlySet +} + +function resolveLimit(requested: number | undefined, maximum: number, name: string): number { + if (requested === undefined) { + return maximum + } + if (!Number.isSafeInteger(requested) || requested < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + return Math.min(requested, maximum) +} + +function assertPortableEntryName(name: string): void { + if (name.length === 0 || name === '.' || name === '..' || /[/\\]/.test(name)) { + throw new Error('Agent config overlay contains a non-portable entry name') + } +} + +function closeDirectory(directory: ReturnType): void { + try { + directory.closeSync() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') { + throw error + } + } +} + +function scanOpenDirectory( + directory: Pick, 'readSync'>, + budget: ConfigOverlayEntryBudget, + visit: (entry: Dirent) => void +): void { + while (true) { + const entry = directory.readSync() + if (entry === null) { + return + } + budget.reserve(entry.name) + visit(entry) + } +} + +function scanDirectory( + path: string, + budget: ConfigOverlayEntryBudget, + visit: (entry: Dirent) => void +): void { + const directory = opendirSync(path, { bufferSize: 32 }) + try { + scanOpenDirectory(directory, budget, visit) + } finally { + closeDirectory(directory) + } +} + +export function createConfigOverlayPlan( + sourceDir: string, + options: ConfigOverlayPlanOptions, + budget = new ConfigOverlayEntryBudget() +): ConfigOverlayPlan { + const topLevelEntryNames: string[] = [] + const pluginEntryNames: string[] = [] + let pluginSourceDir: string | null = null + + scanDirectory(sourceDir, budget, (entry) => { + if (options.reservedTopLevelEntryNames?.has(entry.name)) { + return + } + + const sourcePath = join(sourceDir, entry.name) + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + pluginSourceDir = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + scanDirectory(pluginSourceDir, budget, (pluginEntry) => { + if (pluginEntry.name !== options.reservedPluginFile) { + pluginEntryNames.push(pluginEntry.name) + } + }) + return + } + } + + topLevelEntryNames.push(entry.name) + }) + + return { sourceDir, topLevelEntryNames, pluginSourceDir, pluginEntryNames } +} + +export function applyConfigOverlayPlan( + plan: ConfigOverlayPlan, + targetDir: string, + applied: AppliedConfigOverlayEntries = { + topLevelEntryNames: [], + pluginEntryNames: [] + } +): AppliedConfigOverlayEntries { + for (const entryName of plan.topLevelEntryNames) { + mirrorEntry(join(plan.sourceDir, entryName), join(targetDir, entryName)) + applied.topLevelEntryNames.push(entryName) + } + + if (plan.pluginSourceDir === null) { + return applied + } + + const targetPluginsDir = join(targetDir, 'plugins') + mkdirSync(targetPluginsDir, { recursive: true }) + for (const entryName of plan.pluginEntryNames) { + mirrorEntry(join(plan.pluginSourceDir, entryName), join(targetPluginsDir, entryName)) + applied.pluginEntryNames.push(entryName) + } + return applied +} + +export const _configOverlayMirroringInternals = { + scanOpenDirectory +} diff --git a/src/main/pty/overlay-mirror.test.ts b/src/main/pty/overlay-mirror.test.ts index 5499d226422..a97a049d9c4 100644 --- a/src/main/pty/overlay-mirror.test.ts +++ b/src/main/pty/overlay-mirror.test.ts @@ -4,7 +4,12 @@ import { tmpdir } from 'node:os' import type * as NodePath from 'node:path' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { safeRemoveOverlay } from './overlay-mirror' +import { + OVERLAY_REMOVE_MAX_DEPTH, + OVERLAY_REMOVE_MAX_ENTRIES, + safeRemoveOverlay, + safeRemoveTree +} from './overlay-mirror' const tempRoots: string[] = [] @@ -19,6 +24,50 @@ afterEach(() => { }) describe('safeRemoveOverlay', () => { + it('publishes finite production cleanup limits', () => { + expect(OVERLAY_REMOVE_MAX_ENTRIES).toBe(100_000) + expect(OVERLAY_REMOVE_MAX_DEPTH).toBe(256) + }) + + it('removes a tree at the exact entry limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-entry-limit-')) + tempRoots.push(root) + writeFileSync(join(root, 'one'), '') + writeFileSync(join(root, 'two'), '') + + expect(safeRemoveTree(root, { maxEntries: 2 })).toBe(true) + expect(existsSync(root)).toBe(false) + }) + + it('stops before visiting an entry beyond the limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-entry-limit-')) + tempRoots.push(root) + writeFileSync(join(root, 'one'), '') + writeFileSync(join(root, 'two'), '') + writeFileSync(join(root, 'three'), '') + + expect(safeRemoveTree(root, { maxEntries: 2 })).toBe(false) + expect(existsSync(root)).toBe(true) + }) + + it('accepts the exact directory depth and stops before descending one level deeper', async () => { + const acceptedRoot = await mkdtemp(join(tmpdir(), 'orca-overlay-depth-limit-')) + tempRoots.push(acceptedRoot) + mkdirSync(join(acceptedRoot, 'one', 'two'), { recursive: true }) + writeFileSync(join(acceptedRoot, 'one', 'two', 'leaf'), '') + + expect(safeRemoveTree(acceptedRoot, { maxDepth: 2 })).toBe(true) + expect(existsSync(acceptedRoot)).toBe(false) + + const rejectedRoot = await mkdtemp(join(tmpdir(), 'orca-overlay-depth-limit-')) + tempRoots.push(rejectedRoot) + mkdirSync(join(rejectedRoot, 'one', 'two'), { recursive: true }) + writeFileSync(join(rejectedRoot, 'one', 'two', 'leaf'), '') + + expect(safeRemoveTree(rejectedRoot, { maxDepth: 1 })).toBe(false) + expect(existsSync(rejectedRoot)).toBe(true) + }) + it('removes valid overlay children whose names start with dot-dot', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) tempRoots.push(root) diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index ec1b99d2b92..eb6206e7cf8 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -11,13 +11,27 @@ import { cpSync, linkSync, lstatSync, - readdirSync, + opendirSync, rmdirSync, symlinkSync, unlinkSync } from 'node:fs' import { isAbsolute, join, relative, resolve, sep } from 'node:path' +export const OVERLAY_REMOVE_MAX_ENTRIES = 100_000 +export const OVERLAY_REMOVE_MAX_DEPTH = 256 + +type OverlayRemoveLimits = { + maxEntries: number + maxDepth: number +} + +type OverlayRemoveState = { + entries: number + exhausted: boolean + limits: OverlayRemoveLimits +} + export function mirrorEntry(sourcePath: string, targetPath: string): void { // Why: lstatSync (not statSync) so that if the user's source dir contains // its OWN symlinks (e.g. skills symlinked from ~/.agents/skills), we mirror @@ -83,67 +97,130 @@ export function isSafeDescendCandidate(stats: { return stats.isDirectory() } -// Why: the overlay tree contains symlinks/junctions that point back into the -// user's real state dir. fs.rmSync with { recursive: true } has repeatedly -// regressed on Windows when walking NTFS junctions -- it can follow them and -// delete the *target*, destroying the user's data. Never descend into a -// symlink/junction here: for any non-real-directory entry we unlink the link -// itself; only entries that are truly directories on disk are recursed into. -export function safeRemoveTree(path: string): void { +function resolveRemoveLimit(requested: number | undefined, maximum: number, name: string): number { + if (requested === undefined) { + return maximum + } + if (!Number.isSafeInteger(requested) || requested < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + return Math.min(requested, maximum) +} + +function closeDirectory(directory: ReturnType): boolean { + try { + directory.closeSync() + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ERR_DIR_CLOSED' + } +} + +function removeTreeWithinLimits(path: string, depth: number, state: OverlayRemoveState): boolean { + if (state.exhausted) { + return false + } + let stat try { stat = lstatSync(path) - } catch { - return + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' } // On Windows, lstat on a directory junction can report BOTH // isSymbolicLink() === true AND isDirectory() === true, so we MUST check // isSymbolicLink first -- otherwise a junction enters the recursive branch - // and readdirSync enumerates the link's target, the exact bug in #1083. + // and enumerates the link's target, the exact bug in #1083. if (!isSafeDescendCandidate(stat)) { try { unlinkSync(path) + return true } catch { // Best-effort: antivirus/indexers can hold handles briefly on Windows. // A leftover link is harmless; the next spawn rebuilds the overlay. + return false } - return } - let entries + let directory try { - entries = readdirSync(path, { withFileTypes: true }) + directory = opendirSync(path, { bufferSize: 32 }) } catch { - return + return false } - for (const entry of entries) { - const child = join(path, entry.name) - if (isSafeDescendCandidate(entry)) { - safeRemoveTree(child) - continue - } - try { - unlinkSync(child) - } catch { - // Best-effort, see above. + let completed = true + try { + while (!state.exhausted) { + const entry = directory.readSync() + if (entry === null) { + break + } + if (state.entries >= state.limits.maxEntries) { + state.exhausted = true + completed = false + break + } + state.entries += 1 + + const child = join(path, entry.name) + if (isSafeDescendCandidate(entry)) { + if (depth >= state.limits.maxDepth) { + state.exhausted = true + completed = false + break + } + completed = removeTreeWithinLimits(child, depth + 1, state) && completed + continue + } + try { + unlinkSync(child) + } catch { + completed = false + } } + } catch { + completed = false + } finally { + completed = closeDirectory(directory) && completed } try { rmdirSync(path) + return completed } catch { // Directory may be non-empty if an unlink above failed; harmless. + return false } } +// Why: the overlay tree contains symlinks/junctions that point back into the +// user's real state dir. Stream a bounded traversal and never descend into a +// link; a pathological tree is left for a later cleanup instead of growing +// an unbounded directory array or call stack in Electron's main process. +export function safeRemoveTree(path: string, requested?: Partial): boolean { + const state: OverlayRemoveState = { + entries: 0, + exhausted: false, + limits: { + maxEntries: resolveRemoveLimit( + requested?.maxEntries, + OVERLAY_REMOVE_MAX_ENTRIES, + 'maxEntries' + ), + maxDepth: resolveRemoveLimit(requested?.maxDepth, OVERLAY_REMOVE_MAX_DEPTH, 'maxDepth') + } + } + return removeTreeWithinLimits(path, 0, state) +} + // Why: last-line guard against an overlay-root constant ever being // mis-resolved. Any caller that points safeRemoveTree at a path outside its // designated overlay root is refused so a misconfiguration cannot turn into // an `rm -rf` of arbitrary user data. Logs (rather than throws) so a buggy // caller stays visible without crashing the PTY spawn. -export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void { +export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): boolean { const resolvedRoot = resolve(overlayRoot) const resolvedTarget = resolve(overlayDir) const rel = relative(resolvedRoot, resolvedTarget) @@ -151,7 +228,7 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void console.warn( `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` ) - return + return false } - safeRemoveTree(resolvedTarget) + return safeRemoveTree(resolvedTarget) } diff --git a/src/main/pty/shell-startup-env-memory.test.ts b/src/main/pty/shell-startup-env-memory.test.ts new file mode 100644 index 00000000000..fe355e4a100 --- /dev/null +++ b/src/main/pty/shell-startup-env-memory.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { readFileSyncMock } = vi.hoisted(() => ({ readFileSyncMock: vi.fn() })) + +vi.mock('../../shared/memory-safety/node-bounded-file-reader', () => ({ + readNodeFileSyncWithinLimit: (path: string, maxBytes: number) => { + const buffer = Buffer.from(readFileSyncMock(path) as string) + if (buffer.byteLength > maxBytes) { + throw new Error('File too large') + } + return { buffer, stats: { size: buffer.byteLength } } + } +})) + +import { + __resetShellStartupEnvCache, + MAX_SHELL_STARTUP_CACHE_BYTES, + MAX_SHELL_STARTUP_CACHE_ENTRIES, + MAX_SHELL_STARTUP_ENV_VALUE_CODE_UNITS, + MAX_SHELL_STARTUP_FILE_BYTES, + readShellStartupEnvVar +} from './shell-startup-env' + +describe('shell startup environment memory bounds', () => { + const originalPlatform = process.platform + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readFileSyncMock.mockReset() + __resetShellStartupEnvCache() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + }) + + it('accepts an exact-size startup file and skips the next byte', () => { + const assignment = 'export TARGET=/bounded\n' + readFileSyncMock.mockReturnValue( + assignment + '#'.repeat(MAX_SHELL_STARTUP_FILE_BYTES - assignment.length) + ) + expect(readShellStartupEnvVar('TARGET', '/home/alice', '/bin/zsh')).toBe('/bounded') + + __resetShellStartupEnvCache() + readFileSyncMock.mockReturnValue( + assignment + '#'.repeat(MAX_SHELL_STARTUP_FILE_BYTES - assignment.length + 1) + ) + expect(readShellStartupEnvVar('TARGET', '/home/alice', '/bin/zsh')).toBeUndefined() + }) + + it('does not retain an oversized exported value', () => { + readFileSyncMock.mockReturnValue( + `export TARGET=${'x'.repeat(MAX_SHELL_STARTUP_ENV_VALUE_CODE_UNITS + 1)}\n` + ) + + expect(readShellStartupEnvVar('TARGET', '/home/alice', '/bin/zsh')).toBeUndefined() + }) + + it('caps cache entries with LRU recovery', () => { + readFileSyncMock.mockReturnValue('') + for (let index = 0; index <= MAX_SHELL_STARTUP_CACHE_ENTRIES; index += 1) { + readShellStartupEnvVar(`TARGET_${index}`, '/home/alice', '/bin/zsh') + } + const readsBeforeRetry = readFileSyncMock.mock.calls.length + + readShellStartupEnvVar('TARGET_0', '/home/alice', '/bin/zsh') + + expect(readFileSyncMock.mock.calls.length).toBeGreaterThan(readsBeforeRetry) + }) + + it('caps aggregate cached key/value memory independently of entry count', () => { + expect(MAX_SHELL_STARTUP_CACHE_BYTES).toBe(8 * 1024 * 1024) + const value = 'x'.repeat(MAX_SHELL_STARTUP_ENV_VALUE_CODE_UNITS) + for (let index = 0; index < 65; index += 1) { + readFileSyncMock.mockReturnValue(`export TARGET_${index}=${value}\n`) + expect(readShellStartupEnvVar(`TARGET_${index}`, '/home/alice', '/bin/zsh')).toBe(value) + } + const readsBeforeRetry = readFileSyncMock.mock.calls.length + readFileSyncMock.mockReturnValue(`export TARGET_0=${value}\n`) + + readShellStartupEnvVar('TARGET_0', '/home/alice', '/bin/zsh') + + expect(readFileSyncMock.mock.calls.length).toBeGreaterThan(readsBeforeRetry) + }) +}) diff --git a/src/main/pty/shell-startup-env.test.ts b/src/main/pty/shell-startup-env.test.ts index d41e6c7fdcb..71ac657c316 100644 --- a/src/main/pty/shell-startup-env.test.ts +++ b/src/main/pty/shell-startup-env.test.ts @@ -10,6 +10,16 @@ vi.mock('fs', () => ({ readFileSync: readFileSyncMock })) +vi.mock('../../shared/memory-safety/node-bounded-file-reader', () => ({ + readNodeFileSyncWithinLimit: (path: string, maxBytes: number) => { + const buffer = Buffer.from(readFileSyncMock(path) as string) + if (buffer.byteLength > maxBytes) { + throw new Error('File too large') + } + return { buffer, stats: { size: buffer.byteLength } } + } +})) + import { __resetShellStartupEnvCache, readShellStartupEnvVar } from './shell-startup-env' describe('readShellStartupEnvVar', () => { diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts index 83a4cad1185..a917844fada 100644 --- a/src/main/pty/shell-startup-env.ts +++ b/src/main/pty/shell-startup-env.ts @@ -1,5 +1,12 @@ -import { existsSync, readFileSync } from 'node:fs' import { posix } from 'node:path' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' + +export const MAX_SHELL_STARTUP_FILE_BYTES = 1024 * 1024 +export const MAX_SHELL_STARTUP_CACHE_ENTRIES = 128 +export const MAX_SHELL_STARTUP_CACHE_BYTES = 8 * 1024 * 1024 +export const MAX_SHELL_STARTUP_ENV_VALUE_CODE_UNITS = 64 * 1024 +const MAX_SHELL_STARTUP_NAME_CODE_UNITS = 256 +const MAX_SHELL_STARTUP_PATH_CODE_UNITS = 16 * 1024 // Why: only files the user's actual shell would source. Mixing zsh and bash // files breaks the "last assignment wins matches the live shell" guarantee — @@ -29,7 +36,7 @@ function parseExportedValue(content: string, name: string, home: string): string // Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would // do for double-quoted and unquoted values; single-quoted is literal. const expanded = quoted === "'" ? text : expandHome(text, home) - if (expanded.length > 0) { + if (expanded.length > 0 && expanded.length <= MAX_SHELL_STARTUP_ENV_VALUE_CODE_UNITS) { lastMatch = expanded } } @@ -38,11 +45,8 @@ function parseExportedValue(content: string, name: string, home: string): string } function readStartupFile(path: string): string | null { - if (!existsSync(path)) { - return null - } try { - return readFileSync(path, 'utf8') + return readNodeFileSyncWithinLimit(path, MAX_SHELL_STARTUP_FILE_BYTES).buffer.toString('utf8') } catch { return null } @@ -121,7 +125,8 @@ function expandHome(value: string, home: string): string { .replace(/\$HOME(?![A-Za-z0-9_])/g, home) } -const cache = new Map() +const cache = new Map() +let cacheRetainedBytes = 0 /** * Best-effort static read of a single env-var assignment from the user's @@ -151,18 +156,26 @@ export function readShellStartupEnvVar( home = process.env.HOME, shell = process.env.SHELL ): string | undefined { - if (!home || process.platform === 'win32') { + if ( + !home || + home.length > MAX_SHELL_STARTUP_PATH_CODE_UNITS || + (shell?.length ?? 0) > MAX_SHELL_STARTUP_PATH_CODE_UNITS || + process.platform === 'win32' + ) { return undefined } // Why: the regex above is fixed; rejecting unsafe names is cheap defense // for the day a future caller passes something with regex metacharacters. - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + if (name.length > MAX_SHELL_STARTUP_NAME_CODE_UNITS || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { return undefined } const cacheKey = `${name}\0${home}\0${shell ?? ''}` - if (cache.has(cacheKey)) { - return cache.get(cacheKey) + const cached = cache.get(cacheKey) + if (cached) { + cache.delete(cacheKey) + cache.set(cacheKey, cached) + return cached.value } let lastMatch: string | undefined @@ -179,10 +192,28 @@ export function readShellStartupEnvVar( } } - cache.set(cacheKey, lastMatch) + cacheShellStartupValue(cacheKey, lastMatch) return lastMatch } +function cacheShellStartupValue(key: string, value: string | undefined): void { + const retainedBytes = (key.length + (value?.length ?? 0)) * 2 + while ( + cache.size >= MAX_SHELL_STARTUP_CACHE_ENTRIES || + cacheRetainedBytes + retainedBytes > MAX_SHELL_STARTUP_CACHE_BYTES + ) { + const oldestKey = cache.keys().next().value as string | undefined + if (oldestKey === undefined) { + return + } + const oldest = cache.get(oldestKey) + cache.delete(oldestKey) + cacheRetainedBytes -= oldest?.retainedBytes ?? 0 + } + cache.set(key, { retainedBytes, value }) + cacheRetainedBytes += retainedBytes +} + /** * Test-only helper to reset the per-process cache between cases. * Why: production callers never invalidate (rc files don't change at @@ -190,4 +221,5 @@ export function readShellStartupEnvVar( */ export function __resetShellStartupEnvCache(): void { cache.clear() + cacheRetainedBytes = 0 } diff --git a/src/main/rate-limits/auth-filesystem-operation.test.ts b/src/main/rate-limits/auth-filesystem-operation.test.ts index 92099fa2dcc..d27dfd381ec 100644 --- a/src/main/rate-limits/auth-filesystem-operation.test.ts +++ b/src/main/rate-limits/auth-filesystem-operation.test.ts @@ -1,5 +1,24 @@ import { describe, expect, it, vi } from 'vitest' -import { createAuthFilesystemOperation } from './auth-filesystem-operation' +import { + AuthFilesystemOperationLimitError, + AuthFilesystemOperationRegistry, + createAuthFilesystemOperation, + MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES, + MAX_AUTH_FILESYSTEM_OPERATION_WAITERS, + MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES, + MAX_QUEUED_WSL_AUTH_OPERATIONS +} from './auth-filesystem-operation' + +function deferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} describe('createAuthFilesystemOperation', () => { it('serializes WSL aliases by distro and drops an abandoned queued read', async () => { @@ -116,4 +135,152 @@ describe('createAuthFilesystemOperation', () => { await expect(Promise.all([ubuntuWait, debianWait])).resolves.toEqual(['ubuntu', 'debian']) expect(fedoraRaw).not.toHaveBeenCalled() }) + + it('caps retained operations, coalesces existing paths at saturation, and recovers', async () => { + const registry = new AuthFilesystemOperationRegistry() + const deferredReads = Array.from({ length: MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES + 1 }, () => + deferred() + ) + const operations = deferredReads + .slice(0, MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) + .map((read, index) => registry.getOrCreate(`/auth/${index}`, () => read.promise)) + + expect(operations.every((operation) => operation !== null)).toBe(true) + expect(registry.size).toBe(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) + expect(registry.getOrCreate('/auth/0', async () => 'duplicate')).toBe(operations[0]) + const overflowRaw = vi.fn(async () => 'overflow') + expect(registry.getOrCreate('/auth/overflow', overflowRaw)).toBeNull() + expect(overflowRaw).not.toHaveBeenCalled() + + deferredReads[0]!.resolve('first') + await expect(operations[0]!.result).resolves.toBe('first') + expect(registry.size).toBe(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES - 1) + + const recovered = registry.getOrCreate( + '/auth/recovered', + () => deferredReads[MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES]!.promise + ) + expect(recovered).not.toBeNull() + expect(registry.size).toBe(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) + + for (let index = 1; index < MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES; index += 1) { + deferredReads[index]!.resolve(`read-${index}`) + } + deferredReads[MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES]!.resolve('recovered') + await Promise.all([ + ...operations.slice(1).map((operation) => operation!.result), + recovered!.result + ]) + expect(registry.size).toBe(0) + }) + + it('accepts an exact-limit auth path and rejects one byte more without starting it', async () => { + const exactPath = 'x'.repeat(MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES) + const exactRaw = vi.fn(async () => 'exact') + const exact = createAuthFilesystemOperation(exactPath, exactRaw) + const controller = new AbortController() + + await expect(exact.wait(controller.signal)).resolves.toBe('exact') + expect(exactRaw).toHaveBeenCalledOnce() + + const oversizedRaw = vi.fn(async () => 'oversized') + const oversized = createAuthFilesystemOperation(`${exactPath}x`, oversizedRaw) + await expect(oversized.wait(controller.signal)).rejects.toBeInstanceOf( + AuthFilesystemOperationLimitError + ) + expect(oversizedRaw).not.toHaveBeenCalled() + }) + + it('caps coalesced waiters and admits another after one leaves', async () => { + const read = deferred() + const operation = createAuthFilesystemOperation('/auth/shared', () => read.promise) + const controllers = Array.from( + { length: MAX_AUTH_FILESYSTEM_OPERATION_WAITERS }, + () => new AbortController() + ) + const waits = controllers.map((controller) => operation.wait(controller.signal)) + + await expect(operation.wait(new AbortController().signal)).rejects.toBeInstanceOf( + AuthFilesystemOperationLimitError + ) + + const abandoned = new Error('first waiter left') + controllers[0]!.abort(abandoned) + await expect(waits[0]).rejects.toBe(abandoned) + const recovered = operation.wait(new AbortController().signal) + + read.resolve('shared') + await expect(recovered).resolves.toBe('shared') + await expect(Promise.all(waits.slice(1))).resolves.toEqual( + Array.from({ length: MAX_AUTH_FILESYSTEM_OPERATION_WAITERS - 1 }, () => 'shared') + ) + }) + + it('caps the WSL wait queue and recovers as soon as a queued operation leaves', async () => { + const ubuntuRead = deferred() + const debianRead = deferred() + const ubuntuRaw = vi.fn(() => ubuntuRead.promise) + const debianRaw = vi.fn(() => debianRead.promise) + const activeController = new AbortController() + const ubuntu = createAuthFilesystemOperation( + '\\\\wsl$\\Ubuntu\\home\\alice\\auth.json', + ubuntuRaw + ) + const debian = createAuthFilesystemOperation( + '\\\\wsl$\\Debian\\home\\alice\\auth.json', + debianRaw + ) + const ubuntuWait = ubuntu.wait(activeController.signal) + const debianWait = debian.wait(activeController.signal) + await Promise.resolve() + await Promise.resolve() + expect(ubuntuRaw).toHaveBeenCalledOnce() + expect(debianRaw).toHaveBeenCalledOnce() + + const queuedControllers: AbortController[] = [] + const queuedWaits: Promise[] = [] + for (let index = 0; index < MAX_QUEUED_WSL_AUTH_OPERATIONS; index += 1) { + const controller = new AbortController() + const operation = createAuthFilesystemOperation( + `\\\\wsl$\\Queued-${index}\\home\\alice\\auth.json`, + async () => `queued-${index}` + ) + queuedControllers.push(controller) + queuedWaits.push(operation.wait(controller.signal)) + } + + const overflowRaw = vi.fn(async () => 'overflow') + const overflow = createAuthFilesystemOperation( + '\\\\wsl$\\Overflow\\home\\alice\\auth.json', + overflowRaw + ) + await expect(overflow.wait(new AbortController().signal)).rejects.toBeInstanceOf( + AuthFilesystemOperationLimitError + ) + expect(overflowRaw).not.toHaveBeenCalled() + + const released = new Error('release queue slot') + queuedControllers[0]!.abort(released) + await expect(queuedWaits[0]).rejects.toBe(released) + + const recoveredController = new AbortController() + const recoveredRaw = vi.fn(async () => 'recovered') + const recovered = createAuthFilesystemOperation( + '\\\\wsl$\\Recovered\\home\\alice\\auth.json', + recoveredRaw + ) + const recoveredWait = recovered.wait(recoveredController.signal) + const recoveredAbort = new Error('recovered queue entry admitted') + recoveredController.abort(recoveredAbort) + await expect(recoveredWait).rejects.toBe(recoveredAbort) + expect(recoveredRaw).not.toHaveBeenCalled() + + for (const controller of queuedControllers.slice(1)) { + controller.abort(new Error('test cleanup')) + } + await Promise.allSettled(queuedWaits.slice(1)) + ubuntuRead.resolve('ubuntu') + debianRead.resolve('debian') + await expect(Promise.all([ubuntuWait, debianWait])).resolves.toEqual(['ubuntu', 'debian']) + }) }) diff --git a/src/main/rate-limits/auth-filesystem-operation.ts b/src/main/rate-limits/auth-filesystem-operation.ts index 228234e92c0..8910f30f19d 100644 --- a/src/main/rate-limits/auth-filesystem-operation.ts +++ b/src/main/rate-limits/auth-filesystem-operation.ts @@ -1,10 +1,22 @@ import { parseWslUncPath } from '../../shared/wsl-paths' const MAX_CONCURRENT_WSL_AUTH_OPERATIONS = 2 +export const MAX_QUEUED_WSL_AUTH_OPERATIONS = 128 +export const MAX_AUTH_FILESYSTEM_OPERATION_WAITERS = 256 +export const MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES = 64 * 1024 +export const MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES = 128 +export const MAX_AUTH_FILESYSTEM_REGISTRY_PATH_BYTES = 8 * 1024 * 1024 const activeWslOperationDistros = new Set() const queuedWslOperations: QueuedWslOperation[] = [] let activeWslOperationCount = 0 +export class AuthFilesystemOperationLimitError extends Error { + constructor(message: string) { + super(message) + this.name = 'AuthFilesystemOperationLimitError' + } +} + type QueuedWslOperation = { distroKey: string neededSignal: AbortSignal @@ -70,6 +82,13 @@ function scheduleWslAuthFilesystemOperation( neededSignal: AbortSignal, operation: () => Promise ): Promise { + if (queuedWslOperations.length >= MAX_QUEUED_WSL_AUTH_OPERATIONS) { + return Promise.reject( + new AuthFilesystemOperationLimitError( + `Auth filesystem queue exceeds ${MAX_QUEUED_WSL_AUTH_OPERATIONS} operations` + ) + ) + } return new Promise((resolve, reject) => { const task: QueuedWslOperation = { distroKey, @@ -126,6 +145,19 @@ export type SharedAuthFilesystemOperation = { wait: (signal: AbortSignal) => Promise } +function rejectedAuthFilesystemOperation( + error: AuthFilesystemOperationLimitError +): SharedAuthFilesystemOperation { + const result = Promise.reject(error) + void result.catch(() => undefined) + return { + result, + wait(signal) { + return Promise.reject(signal.aborted ? getAbortReason(signal) : error) + } + } +} + /** * Shares one raw operation with all callers for an auth path. WSL paths also * serialize by normalized distro because one stuck UNC request per account can @@ -135,8 +167,15 @@ export function createAuthFilesystemOperation( authPath: string, operation: () => Promise ): SharedAuthFilesystemOperation { + if (Buffer.byteLength(authPath, 'utf8') > MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES) { + return rejectedAuthFilesystemOperation( + new AuthFilesystemOperationLimitError( + `Auth filesystem path exceeds ${MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES} bytes` + ) + ) + } const neededController = new AbortController() - const waiters = new Set() + let waiterCount = 0 let settled = false const result = scheduleAuthFilesystemOperation(authPath, neededController.signal, operation) const markSettled = (): void => { @@ -148,14 +187,23 @@ export function createAuthFilesystemOperation( result, wait(signal) { if (signal.aborted) { - if (!settled && waiters.size === 0) { + if (!settled && waiterCount === 0) { neededController.abort(getAbortReason(signal)) } return Promise.reject(getAbortReason(signal)) } - const waiter = Symbol('auth-filesystem-waiter') - waiters.add(waiter) + if (settled) { + return result + } + if (waiterCount >= MAX_AUTH_FILESYSTEM_OPERATION_WAITERS) { + return Promise.reject( + new AuthFilesystemOperationLimitError( + `Auth filesystem operation exceeds ${MAX_AUTH_FILESYSTEM_OPERATION_WAITERS} waiters` + ) + ) + } + waiterCount += 1 let onAbort: (() => void) | null = null const aborted = new Promise((_resolve, reject) => { onAbort = () => reject(getAbortReason(signal)) @@ -165,11 +213,53 @@ export function createAuthFilesystemOperation( if (onAbort) { signal.removeEventListener('abort', onAbort) } - waiters.delete(waiter) - if (!settled && waiters.size === 0) { + waiterCount -= 1 + if (!settled && waiterCount === 0) { neededController.abort(getAbortReason(signal)) } }) } } } + +export class AuthFilesystemOperationRegistry { + private readonly operations = new Map>() + private retainedPathBytes = 0 + + get size(): number { + return this.operations.size + } + + getOrCreate( + authPath: string, + operation: () => Promise + ): SharedAuthFilesystemOperation | null { + const pathBytes = Buffer.byteLength(authPath, 'utf8') + if (pathBytes > MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES) { + return null + } + const existing = this.operations.get(authPath) + if (existing) { + return existing + } + if ( + this.operations.size >= MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES || + pathBytes > MAX_AUTH_FILESYSTEM_REGISTRY_PATH_BYTES - this.retainedPathBytes + ) { + return null + } + + const shared = createAuthFilesystemOperation(authPath, operation) + this.operations.set(authPath, shared) + this.retainedPathBytes += pathBytes + const clear = (): void => { + if (this.operations.get(authPath) !== shared) { + return + } + this.operations.delete(authPath) + this.retainedPathBytes -= pathBytes + } + void shared.result.then(clear, clear) + return shared + } +} diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 7eb218f04de..c46c9577951 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -15,18 +15,25 @@ import { } from '../claude-accounts/keychain' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) +const { + netFetchMock, + readFileMock, + readFileSyncMock, + resolveProxyMock, + setProxyMock, + appGetPathMock +} = vi.hoisted(() => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + readFileSyncMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() +})) -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock +vi.mock('../integration-credential-file', () => ({ + readIntegrationCredentialFileSyncText: readFileSyncMock, + readIntegrationCredentialFileText: readFileMock })) vi.mock('electron', () => ({ @@ -74,6 +81,7 @@ describe('fetchClaudeRateLimits', () => { tempDir = null vi.clearAllMocks() readFileMock.mockRejectedValue(new Error('missing file')) + readFileSyncMock.mockImplementation((filePath: string) => readFileSync(filePath, 'utf8')) vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null) @@ -744,10 +752,7 @@ describe('fetchClaudeRateLimits', () => { status: 'ok' }) - expect(readFileMock).toHaveBeenCalledWith( - join('/Users/test/.claude', '.credentials.json'), - 'utf-8' - ) + expect(readFileMock).toHaveBeenCalledWith(join('/Users/test/.claude', '.credentials.json')) expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 206748e1bc5..ddc1237fcde 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: keep Claude credential ordering, OAuth usage fetch, and PTY fallback together so usage state can't drift across paths. */ -import { existsSync, lstatSync, readFileSync } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { existsSync, lstatSync } from 'node:fs' import { homedir } from 'node:os' import path from 'node:path' import { net, session } from 'electron' @@ -42,6 +41,11 @@ import { classifyClaudeOAuthUsageError, type ClaudeUsageErrorClassification } from './claude-usage-error-classification' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' +import { + readIntegrationCredentialFileSyncText, + readIntegrationCredentialFileText +} from '../integration-credential-file' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' @@ -193,7 +197,7 @@ async function readCredentialsFromStrictKeychain( async function readFromCredentialsFile(configDir?: string): Promise { const credPath = path.join(configDir ?? path.join(homedir(), '.claude'), '.credentials.json') try { - const raw = await readFile(credPath, 'utf-8') + const raw = await readIntegrationCredentialFileText(credPath) return parseOAuthCredentialsJson(raw, 'credentials-file') } catch { return emptyOAuthCredentialReadResult() @@ -366,7 +370,7 @@ async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise(res) if (signal?.aborted) { return abortedClaudeRateLimitResult() } @@ -1042,7 +1046,7 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccountInfo if ( !existsSync(markerPath) || lstatSync(markerPath).isSymbolicLink() || - readFileSync(markerPath, 'utf-8').trim() !== account.id + readIntegrationCredentialFileSyncText(markerPath).trim() !== account.id ) { return null } diff --git a/src/main/rate-limits/claude-oauth-usage-error.ts b/src/main/rate-limits/claude-oauth-usage-error.ts index 1ccb1471de6..06d404c5c30 100644 --- a/src/main/rate-limits/claude-oauth-usage-error.ts +++ b/src/main/rate-limits/claude-oauth-usage-error.ts @@ -1,3 +1,5 @@ +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' + // Why: a corrupt/hostile Retry-After must not gate usage refreshes for days. const MAX_RETRY_AFTER_MS = 24 * 60 * 60 * 1000 @@ -45,7 +47,7 @@ async function describeOAuthUsageError(res: Response): Promise { return 'Claude usage is rate limited right now.' } try { - const data = (await res.json()) as { error?: { message?: string } } + const data = await readFetchResponseJsonWithinLimit<{ error?: { message?: string } }>(res) if (typeof data.error?.message === 'string' && data.error.message.trim()) { return data.error.message } diff --git a/src/main/rate-limits/claude-pty-reset-parser.ts b/src/main/rate-limits/claude-pty-reset-parser.ts index 43137a8cea5..7cadfe3fba4 100644 --- a/src/main/rate-limits/claude-pty-reset-parser.ts +++ b/src/main/rate-limits/claude-pty-reset-parser.ts @@ -15,9 +15,6 @@ const MONTH_DAY_TIME_RE = new RegExp( const WEEKDAY_TIME_RE = /\b(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?)\.?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i const TIME_ONLY_RE = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i -// Why: newer Codex CLIs print 24-hour reset times ("10:21 on 28 Jul") with no am/pm. -const TIME_24H_RE = /\b(\d{1,2}):(\d{2})\b/ -const DAY_MONTH_RE = new RegExp(`\\b(?:on\\s+)?(\\d{1,2})\\s+(${MONTH_PATTERN})\\b`, 'i') const RELATIVE_RESET_RE = /^(?:\s*\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)\s*)+$/i const RELATIVE_RESET_TOKEN_RE = /(\d+)\s*(d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)/gi const IANA_TIME_ZONE_RE = /\(([^()]*)\)?\s*$/ @@ -97,16 +94,13 @@ export function extractClaudePtyResetMetadata( function normalizeResetDescription(raw: string): string { // Why: Claude's TUI occasionally drops spaces around the Fable reset date // when copied from the PTY buffer, but the value still encodes a real reset. - return ( - raw - .trim() - // Why: PTY captures keep trailing box-border glyphs from framed status panels. - .replace(/[)\s│]+$/, '') - .replace(/\s+/g, ' ') - .replace(MONTH_DAY_COMPACT_RE, '$1 $2') - .replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2') - .replace(/(\d)(am|pm)\(/gi, '$1$2 (') - ) + return raw + .trim() + .replace(/[)]+$/, '') + .replace(/\s+/g, ' ') + .replace(MONTH_DAY_COMPACT_RE, '$1 $2') + .replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2') + .replace(/(\d)(am|pm)\(/gi, '$1$2 (') } function parseResetTimestamp(resetDescription: string | null): number | null { @@ -118,53 +112,10 @@ function parseResetTimestamp(resetDescription: string | null): number | null { parseRelativeResetTimestamp(resetDescription) ?? parseMonthDayResetTimestamp(resetDescription) ?? parseWeekdayResetTimestamp(resetDescription) ?? - parseTimeOnlyResetTimestamp(resetDescription) ?? - parseTwentyFourHourResetTimestamp(resetDescription) + parseTimeOnlyResetTimestamp(resetDescription) ) } -function parseTwentyFourHourResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const timeMatch = TIME_24H_RE.exec(resetText) - if (!timeMatch) { - return null - } - const hour = Number(timeMatch[1]) - const minute = Number(timeMatch[2]) - if (!isValidClockTime(hour, minute)) { - return null - } - - const dayMonthMatch = DAY_MONTH_RE.exec(resetText) - if (dayMonthMatch) { - const day = Number(dayMonthMatch[1]) - const monthIndex = MONTH_INDEX_BY_NAME[dayMonthMatch[2].toLowerCase()] - if (monthIndex === undefined || day < 1 || day > 31) { - return null - } - const now = new Date() - const timeZone = extractResetTimeZone(resetDescription) - let timestamp = buildWallClockTimestamp( - { year: now.getFullYear(), monthIndex, day, hour, minute }, - timeZone - ) - if (timestamp !== null && timestamp <= Date.now()) { - timestamp = buildWallClockTimestamp( - { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, - timeZone - ) - } - return timestamp - } - - const candidate = new Date() - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 1) - } - return candidate.getTime() -} - function parseRelativeResetTimestamp(resetDescription: string): number | null { if (!RELATIVE_RESET_RE.test(resetDescription)) { return null diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index b7a8c96dfba..054e2ce1e2a 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -13,6 +13,7 @@ import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' +import { appendRateLimitPtyOutputTail } from './rate-limit-pty-output-tail' const PTY_TIMEOUT_MS = 25_000 const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit @@ -441,13 +442,10 @@ export async function fetchViaPty(options?: { }, STARTUP_DELAY_MS) const onDataDisposable = term.onData((data) => { - output += data - // Why: prevent memory exhaustion if the CLI process floods output - if (output.length > MAX_OUTPUT_LENGTH) { - output = output.slice(-MAX_OUTPUT_LENGTH) - } + const appended = appendRateLimitPtyOutputTail(output, data, MAX_OUTPUT_LENGTH) + output = appended.output - const cleanChunk = stripTerminalControlSequences(data) + const cleanChunk = stripTerminalControlSequences(appended.scannedChunk) // Why: the Claude CLI may prompt for first-run setup (trust files, // workspace directory). Auto-accept so we can reach /usage. diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index 9102e54ab09..7c7cff90a4d 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -9,13 +9,19 @@ const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ })) vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) -vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('../integration-credential-file', () => ({ + readIntegrationCredentialFileText: readFileMock +})) vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) vi.mock('./codex-auth-presence', () => ({ probeCodexAuthPresence: vi.fn(async () => 'present') })) import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' +import { + AuthFilesystemOperationLimitError, + MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES +} from './auth-filesystem-operation' describe('Codex backend rate-limit requests', () => { beforeEach(() => { @@ -113,7 +119,7 @@ describe('Codex backend rate-limit requests', () => { const second = fetchCodexRateLimits({ codexHomePath, signal: secondController.signal }) await vi.advanceTimersByTimeAsync(0) expect(readFileMock).toHaveBeenCalledTimes(1) - expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json'), 'utf8') + expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json')) firstController.abort() secondController.abort() @@ -154,7 +160,7 @@ describe('Codex backend rate-limit requests', () => { await vi.advanceTimersByTimeAsync(0) // Why: redeem is user-triggered, so it gets the longer redeem deadline. expect(timeout).toHaveBeenCalledWith(30_000) - expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8') + expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json')) timeoutController.abort(deadlineError) @@ -217,4 +223,59 @@ describe('Codex backend rate-limit requests', () => { ).rejects.toThrow('Codex reset failed: HTTP 429') expect(cancelledBodies).toBe(1) }) + + it('caps distinct stalled backend auth reads and recovers after one settles', async () => { + const authResolvers = new Map void>() + readFileMock.mockImplementation( + (authPath: string) => + new Promise((resolve) => { + authResolvers.set(authPath, resolve) + }) + ) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ code: 'already_redeemed' }) + } as Response) + const requests = Array.from({ length: MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES }, (_, index) => + consumeCodexRateLimitResetCredit({ + codexHomePath: `/managed/saturated-${index}`, + idempotencyKey: `saturated-${index}` + }) + ) + await vi.advanceTimersByTimeAsync(0) + + expect(readFileMock).toHaveBeenCalledTimes(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) + await expect( + consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/overflow', + idempotencyKey: 'overflow' + }) + ).rejects.toBeInstanceOf(AuthFilesystemOperationLimitError) + expect(readFileMock).toHaveBeenCalledTimes(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) + + const authJson = JSON.stringify({ + tokens: { access_token: 'capacity-token', account_id: 'capacity-account' } + }) + authResolvers.get(join('/managed/saturated-0', 'auth.json'))!(authJson) + await vi.advanceTimersByTimeAsync(0) + await expect(requests[0]).resolves.toBe('alreadyRedeemed') + + const recovered = consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/recovered', + idempotencyKey: 'recovered' + }) + await vi.advanceTimersByTimeAsync(0) + expect(readFileMock).toHaveBeenCalledTimes(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES + 1) + authResolvers.get(join('/managed/recovered', 'auth.json'))!(authJson) + await vi.advanceTimersByTimeAsync(0) + await expect(recovered).resolves.toBe('alreadyRedeemed') + + for (let index = 1; index < MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES; index += 1) { + authResolvers.get(join(`/managed/saturated-${index}`, 'auth.json'))!(authJson) + } + await vi.advanceTimersByTimeAsync(0) + await expect(Promise.all(requests.slice(1))).resolves.toEqual( + Array.from({ length: MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES - 1 }, () => 'alreadyRedeemed') + ) + }) }) diff --git a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts index 15c338aad89..c5088a2ae77 100644 --- a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts +++ b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts @@ -61,14 +61,12 @@ describe('fetchCodexRateLimits PTY settle timers', () => { } onPtyData('>') - // Pending: PTY timeout + the delayed /status Enter keypress. - expect(vi.getTimerCount()).toBe(2) + expect(vi.getTimerCount()).toBe(1) onPtyData('5h limit: 17%\n') onPtyData('Weekly limit: 23%\n') onPtyData('still rendering\n') - // One settle timer armed — not one per data chunk. - expect(vi.getTimerCount()).toBe(3) + expect(vi.getTimerCount()).toBe(2) await vi.advanceTimersByTimeAsync(500) @@ -78,223 +76,4 @@ describe('fetchCodexRateLimits PTY settle timers', () => { status: 'ok' }) }) - - it('keeps the reset text on the weekly window for weekly-only plans', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write: vi.fn(), - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - onPtyData('>') - onPtyData('Weekly limit: 76%\nResets in 5d 23h\n') - - await vi.advanceTimersByTimeAsync(500) - - const fiveDays23h = (5 * 24 + 23) * 60 * 60 * 1000 - await expect(resultPromise).resolves.toMatchObject({ - session: null, - weekly: { - usedPercent: 76, - resetDescription: '5d 23h', - resetsAt: Date.now() + fiveDays23h - }, - status: 'ok' - }) - }) - - it('keeps each window reset text on its own window for dual-window plans', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write: vi.fn(), - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - onPtyData('>') - onPtyData('5h limit: 17% (resets in 2h 30m)\nWeekly limit: 23% (resets in 5d 3h)\n') - - await vi.advanceTimersByTimeAsync(500) - - await expect(resultPromise).resolves.toMatchObject({ - session: { - usedPercent: 17, - resetDescription: '2h 30m', - resetsAt: Date.now() + (2 * 60 + 30) * 60 * 1000 - }, - weekly: { - usedPercent: 23, - resetDescription: '5d 3h', - resetsAt: Date.now() + (5 * 24 + 3) * 60 * 60 * 1000 - }, - status: 'ok' - }) - }) - - it('parses the framed codex 0.145 status panel via the /status nudge', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - const write = vi.fn() - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write, - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - // codex ≥0.145 shows a '›' composer with placeholder text, never a bare '>' prompt. - onPtyData('›Summarize recent commits') - expect(write).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(2500) - expect(write).toHaveBeenCalledWith('/status') - await vi.advanceTimersByTimeAsync(350) - expect(write).toHaveBeenCalledWith('\r') - - onPtyData( - '│ Weekly limit: \x1b[?2026h\x1b[0 q[█████████░░░░░░░░░░░] 43% left\x1b[?2026l (resets 10:21 on 28 Jul) │\n' + - '│ GPT-5.3-Codex-Spark Weekly limit: [████████████████████] 100% left (resets 17:40 on 29 Jul) │\n' - ) - await vi.advanceTimersByTimeAsync(500) - - const expectedReset = new Date(new Date().getFullYear(), 6, 28, 10, 21) - if (expectedReset.getTime() <= Date.now()) { - expectedReset.setFullYear(expectedReset.getFullYear() + 1) - } - await expect(resultPromise).resolves.toMatchObject({ - session: null, - weekly: { - usedPercent: 57, - resetDescription: '10:21 on 28 Jul', - resetsAt: expectedReset.getTime() - }, - status: 'ok' - }) - }) - - it('never selects a model-scoped weekly row even when it renders first', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write: vi.fn(), - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - onPtyData('>') - onPtyData( - '│ GPT-5.3-Codex-Spark Weekly limit: [████████████████████] 100% left (resets 17:40 on 29 Jul) │\n' + - '│ Weekly limit: [█████████░░░░░░░░░░░] 43% left (resets 10:21 on 28 Jul) │\n' - ) - await vi.advanceTimersByTimeAsync(500) - - await expect(resultPromise).resolves.toMatchObject({ - session: null, - weekly: { usedPercent: 57, resetDescription: '10:21 on 28 Jul' }, - status: 'ok' - }) - }) - - it('re-sends Enter once when the panel does not render after the first submit', async () => { - const ptyHandlers: { onData?: (data: string) => void } = {} - const write = vi.fn() - - childSpawnMock.mockImplementation(() => { - throw new Error('rpc unavailable') - }) - ptySpawnMock.mockReturnValue({ - onData: vi.fn((callback) => { - ptyHandlers.onData = callback - return makeDisposable() - }), - onExit: vi.fn(() => makeDisposable()), - write, - kill: vi.fn() - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const onPtyData = ptyHandlers.onData - if (!onPtyData) { - throw new Error('PTY data handler was not registered') - } - - onPtyData('>') - await vi.advanceTimersByTimeAsync(350) - expect(write.mock.calls.filter((call) => call[0] === '\r')).toHaveLength(1) - - await vi.advanceTimersByTimeAsync(3000) - expect(write.mock.calls.filter((call) => call[0] === '\r')).toHaveLength(2) - - onPtyData('Weekly limit: 76%\nResets in 5d 23h\n') - await vi.advanceTimersByTimeAsync(500) - - await expect(resultPromise).resolves.toMatchObject({ - session: null, - weekly: { usedPercent: 76 }, - status: 'ok' - }) - }) }) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index a00d86ffc9d..237909137b2 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -13,8 +13,8 @@ vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock +vi.mock('../integration-credential-file', () => ({ + readIntegrationCredentialFileText: readFileMock })) vi.mock('../codex-cli/command', () => ({ @@ -496,7 +496,7 @@ describe('fetchCodexRateLimits', () => { } ] }) - expect(readFileMock).toHaveBeenCalledWith(join('/managed/codex-home', 'auth.json'), 'utf8') + expect(readFileMock).toHaveBeenCalledWith(join('/managed/codex-home', 'auth.json')) expect(fetch).toHaveBeenCalledWith( 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', expect.objectContaining({ diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 28a97a37da6..7d9037e3eed 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -5,12 +5,12 @@ import type { RateLimitWindow } from '../../shared/rate-limit-types' import { spawn } from 'node:child_process' -import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' +import { readIntegrationCredentialFileText } from '../integration-credential-file' import { join } from 'node:path' import { probeCodexAuthPresence } from './codex-auth-presence' -import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' import { classifyCodexRateLimitWindows, CODEX_SESSION_WINDOW_MINUTES, @@ -33,26 +33,21 @@ import { resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' import { - createAuthFilesystemOperation, + AuthFilesystemOperationLimitError, + AuthFilesystemOperationRegistry, type SharedAuthFilesystemOperation } from './auth-filesystem-operation' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' +import { appendRateLimitPtyOutputTail } from './rate-limit-pty-output-tail' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 const PTY_TIMEOUT_MS = 15_000 -// Why: codex ≥0.145 renders a '›' composer with placeholder text after it, so a -// prompt-anchored send can never fire; nudge /status after a short boot grace. -const PTY_STATUS_NUDGE_MS = 2_500 -// Why: '/status\r' in one write coalesces into a paste-like chunk and the TUI -// inserts the newline instead of submitting; Enter must be its own keypress. -const PTY_STATUS_ENTER_DELAY_MS = 350 -// Why: slow hosts (WSL/SSH) can drop the first Enter while the TUI is still -// booting; one spare Enter is a no-op on an empty, ready composer. -const PTY_STATUS_ENTER_RETRY_MS = 3_000 const BACKEND_TIMEOUT_MS = 10_000 // Why: redeeming a reset credit is an explicit user action, not a poll — allow more time for a slow backend. const REDEEM_BACKEND_TIMEOUT_MS = 30_000 const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 +export const MAX_RPC_RESPONSE_LINE_BYTES = 4 * 1024 * 1024 export type FetchCodexRateLimitsOptions = { codexHomePath?: string | null @@ -140,10 +135,7 @@ type BackendAuthReadResult = | { content: string; error?: never } | { content?: never; error: unknown } -const backendAuthReadByPath = new Map< - string, - SharedAuthFilesystemOperation ->() +const backendAuthReads = new AuthFilesystemOperationRegistry() function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` @@ -302,30 +294,22 @@ function createBackendRequestSignal( function getBackendAuthRead( authPath: string -): SharedAuthFilesystemOperation { - const existing = backendAuthReadByPath.get(authPath) - if (existing) { - return existing - } +): SharedAuthFilesystemOperation | null { // Why: Node can't cancel an in-flight UNC read; keep one read per auth path so repeated refreshes don't stack them. - const read = createAuthFilesystemOperation(authPath, () => - readFile(authPath, 'utf8').then( + return backendAuthReads.getOrCreate(authPath, () => + readIntegrationCredentialFileText(authPath).then( (content) => ({ content }), (error: unknown) => ({ error }) ) ) - backendAuthReadByPath.set(authPath, read) - const clearRead = (): void => { - if (backendAuthReadByPath.get(authPath) === read) { - backendAuthReadByPath.delete(authPath) - } - } - void read.result.then(clearRead, clearRead) - return read } async function readBackendAuth(authPath: string, signal: AbortSignal): Promise { - const result = await getBackendAuthRead(authPath).wait(signal) + const read = getBackendAuthRead(authPath) + if (!read) { + throw new AuthFilesystemOperationLimitError('Codex backend auth read capacity exceeded') + } + const result = await read.wait(signal) if ('error' in result) { throw result.error } @@ -381,7 +365,8 @@ async function fetchBackendRateLimitResetCredits( await cancelUnreadResponseBody(response) return null } - const payload = (await response.json()) as BackendRateLimitResetCreditsResponse + const payload = + await readFetchResponseJsonWithinLimit(response) return mapBackendRateLimitResetCredits(payload) ?? null } @@ -449,7 +434,8 @@ export async function consumeCodexRateLimitResetCredit(options: { await cancelUnreadResponseBody(response) throw new Error(`Codex reset failed: HTTP ${response.status}`) } - const payload = (await response.json()) as BackendConsumeRateLimitResetCreditResponse + const payload = + await readFetchResponseJsonWithinLimit(response) return mapBackendConsumeOutcome(payload.code) } @@ -529,7 +515,7 @@ async function fetchViaBackend( await cancelUnreadResponseBody(response) return null } - const payload = (await response.json()) as BackendUsageResponse + const payload = await readFetchResponseJsonWithinLimit(response) // Why: plan_type is required by Codex's RateLimitStatusPayload; reject malformed JSON so the app-server fallback still runs. if (typeof payload.plan_type !== 'string') { return null @@ -561,8 +547,8 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise((resolve) => { - let buffer = '' - let stderr = '' + const buffer = new GrowingByteBuffer() + const stderr = new GrowingByteBuffer() let resolved = false let rpcId = 0 @@ -609,6 +595,8 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise MAX_RPC_RESPONSE_LINE_BYTES - buffer.byteLength) { + settle( + { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `RPC response exceeded ${MAX_RPC_RESPONSE_LINE_BYTES} byte line limit`, + status: 'error' + }, + { kill: true } + ) + return + } + buffer.append(segment) + if (!hasNewline) { + return + } + offset = newlineIdx + 1 + const line = buffer.takeString('utf8').trim() if (!line) { continue } @@ -697,7 +706,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise MAX_DIAGNOSTIC_OUTPUT_LENGTH) { - stderr = stderr.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH) - } + stderr.appendRetainedSuffix(chunk, MAX_DIAGNOSTIC_OUTPUT_LENGTH) } function onError(err: Error): void { @@ -753,7 +758,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise FIVE_HOUR_RE.test(line), - isPtyLimitLabel - ) - const weeklyReset = extractClaudePtyResetMetadata( - lines, - (line) => WEEKLY_RE.test(line), - isPtyLimitLabel - ) const session: RateLimitWindow | null = fiveMatch ? { - usedPercent: ptyUsedPercent(fiveMatch), + usedPercent: Math.min(100, Number.parseInt(fiveMatch[1], 10)), windowMinutes: 300, - resetsAt: sessionReset.resetsAt, - resetDescription: sessionReset.resetDescription + resetsAt: null, + resetDescription: null } : null const weekly: RateLimitWindow | null = weeklyMatch ? { - usedPercent: ptyUsedPercent(weeklyMatch), + usedPercent: Math.min(100, Number.parseInt(weeklyMatch[1], 10)), windowMinutes: 10080, - resetsAt: weeklyReset.resetsAt, - resetDescription: weeklyReset.resetDescription + resetsAt: null, + resetDescription: null } : null + // Try to extract reset time from surrounding text + const resetMatch = RESET_TEXT_RE.exec(output) + if (resetMatch && session) { + session.resetDescription = resetMatch[1].trim() + } + return { session, weekly } } @@ -885,53 +860,6 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise void }[] = [registerHiddenRateLimitPty(term)] - let statusEnter: ReturnType | null = null - function sendStatusCommand(): void { - sentStatus = true - if (statusNudge) { - clearTimeout(statusNudge) - statusNudge = null - } - term.write('/status') - statusEnter = setTimeout(() => { - statusEnter = null - term.write('\r') - statusEnter = setTimeout(() => { - statusEnter = null - if (!resolved && !settleTimer) { - term.write('\r') - } - }, PTY_STATUS_ENTER_RETRY_MS) - }, PTY_STATUS_ENTER_DELAY_MS) - } - - let statusNudge: ReturnType | null = null - // Why: count the nudge grace from first TUI output, not spawn, so slow - // WSL/SSH boots get the full window before /status is typed. - function armStatusNudge(): void { - if (statusNudge || sentStatus || resolved) { - return - } - statusNudge = setTimeout(() => { - statusNudge = null - if (!resolved && !sentStatus) { - sendStatusCommand() - } - }, PTY_STATUS_NUDGE_MS) - } - termDisposables.push({ - dispose: () => { - if (statusNudge) { - clearTimeout(statusNudge) - statusNudge = null - } - if (statusEnter) { - clearTimeout(statusEnter) - statusEnter = null - } - } - }) - function settleAborted(): void { if (resolved) { return @@ -980,24 +908,18 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { - output += data - // Why: only recent status output is needed; cap noisy TUI output like the Claude fallback. - if (output.length > MAX_DIAGNOSTIC_OUTPUT_LENGTH) { - output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH) - } - - armStatusNudge() + const appended = appendRateLimitPtyOutputTail(output, data, MAX_DIAGNOSTIC_OUTPUT_LENGTH) + output = appended.output // Wait for prompt, then send /status - if (!sentStatus && /[>›]\s*$/.test(data)) { - sendStatusCommand() + if (!sentStatus && />\s*$/.test(appended.scannedChunk)) { + sentStatus = true + term.write('/status\r') return } // Check if we have parseable output - // Why: colored meter bars embed digits inside CSI sequences, so probe cleaned text. - const probe = sentStatus && !settleTimer ? stripPtyControlSequences(output) : null - if (probe !== null && (FIVE_HOUR_RE.test(probe) || WEEKLY_RE.test(probe))) { + if (sentStatus && !settleTimer && (FIVE_HOUR_RE.test(output) || WEEKLY_RE.test(output))) { // Why: the TUI keeps streaming after status is parseable; one settle timer lets the panel finish flushing. settleTimer = setTimeout(() => { settleTimer = null @@ -1011,7 +933,8 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { try { @@ -21,8 +28,13 @@ const OAUTH2_SUBPATH = path.join('dist', 'src', 'code_assist', 'oauth2.js') async function resolveGeminiBinary(): Promise { const whichCmd = process.platform === 'win32' ? 'where gemini' : 'which gemini' try { - const { stdout } = await execAsync(whichCmd, { encoding: 'utf-8' }) - const fromPath = stdout.trim().split(/\r?\n/)[0] + const { stdout } = await execAsync(whichCmd, { + encoding: 'utf-8', + maxBuffer: MAX_BINARY_LOOKUP_OUTPUT_BYTES + }) + const trimmedOutput = stdout.trim() + const firstLineEnd = trimmedOutput.search(/\r?\n/) + const fromPath = trimmedOutput.slice(0, firstLineEnd === -1 ? undefined : firstLineEnd) if (fromPath && (await fileExists(fromPath))) { return fromPath } @@ -69,17 +81,35 @@ function parseOAuthCredentials(content: string): { clientId: string; clientSecre return null } -async function tryReadCredentials( - filePath: string -): Promise<{ clientId: string; clientSecret: string } | null> { +type GeminiOAuthSourceRead = { + credentials: { clientId: string; clientSecret: string } | null + bytesRead: number +} + +async function readOAuthSource( + filePath: string, + maxBytes = MAX_GEMINI_CLI_OAUTH_SOURCE_BYTES +): Promise { try { - const content = await readFile(filePath, 'utf-8') - return parseOAuthCredentials(content) + const { buffer } = await readNodeFileWithinLimit( + filePath, + Math.min(MAX_GEMINI_CLI_OAUTH_SOURCE_BYTES, maxBytes) + ) + return { + credentials: parseOAuthCredentials(buffer.toString('utf8')), + bytesRead: buffer.length + } } catch { return null } } +export async function readGeminiOAuthCredentialsFile( + filePath: string +): Promise<{ clientId: string; clientSecret: string } | null> { + return (await readOAuthSource(filePath))?.credentials ?? null +} + // Why: these are the known stable layouts for every major Gemini CLI install method. // Checking explicit paths is fast and avoids walking the entire directory tree. async function extractFromKnownPaths( @@ -131,7 +161,7 @@ async function extractFromKnownPaths( ] for (const candidate of candidates) { - const creds = await tryReadCredentials(path.normalize(candidate)) + const creds = await readGeminiOAuthCredentialsFile(path.normalize(candidate)) if (creds) { return creds } @@ -143,50 +173,79 @@ async function extractFromKnownPaths( // Why: newer Gemini CLI versions (>=0.38) ship everything bundled into hash-named // chunks with no oauth2.js source file. Scanning the bundle dir for the credential // constants is the only reliable fallback for those installs. -async function extractFromBundleDir( +export async function extractGeminiOAuthCredentialsFromBundleDir( geminiCliPackageRoot: string ): Promise<{ clientId: string; clientSecret: string } | null> { const bundleDir = path.join(geminiCliPackageRoot, 'bundle') - if (!(await fileExists(bundleDir))) { - return null - } - - let entries: string[] + let directory: Awaited> try { - entries = (await readdir(bundleDir)).filter((f) => f.endsWith('.js')) + directory = await opendir(bundleDir, { bufferSize: 32 }) } catch { return null } - for (const entry of entries) { - const creds = await tryReadCredentials(path.join(bundleDir, entry)) - if (creds) { - return creds + let inspectedEntries = 0 + let inspectedFiles = 0 + let inspectedBytes = 0 + try { + while ( + inspectedEntries < MAX_GEMINI_CLI_BUNDLE_ENTRIES && + inspectedFiles < MAX_GEMINI_CLI_BUNDLE_FILES + ) { + const entry = await directory.read() + if (!entry) { + return null + } + inspectedEntries += 1 + if (!entry.name.endsWith('.js')) { + continue + } + inspectedFiles += 1 + const remainingBytes = MAX_GEMINI_CLI_BUNDLE_BYTES - inspectedBytes + if (remainingBytes === 0) { + return null + } + const source = await readOAuthSource(path.join(bundleDir, entry.name), remainingBytes) + if (!source) { + continue + } + if (source.bytesRead > MAX_GEMINI_CLI_BUNDLE_BYTES - inspectedBytes) { + return null + } + inspectedBytes += source.bytesRead + if (source.credentials) { + return source.credentials + } + } + return null + } catch { + return null + } finally { + try { + await directory.close() + } catch { + // Cleanup failure must not mask credential discovery. } } - - return null } // Resolves the gemini-cli package root directory by walking up the directory // tree from the real binary path, looking for package.json with the right name, // or the global Node layout under lib/node_modules. -async function findGeminiPackageRoot(realGeminiPath: string): Promise { +export async function findGeminiPackageRoot(realGeminiPath: string): Promise { const MAX_ASCENTS = 8 let current = path.dirname(realGeminiPath) for (let i = 0; i <= MAX_ASCENTS; i++) { const pkgJson = path.join(current, 'package.json') - if (await fileExists(pkgJson)) { - try { - const raw = await readFile(pkgJson, 'utf-8') - const pkg = JSON.parse(raw) as { name?: string } - if (pkg.name === '@google/gemini-cli') { - return current - } - } catch { - // malformed package.json — keep walking + try { + const { buffer } = await readNodeFileWithinLimit(pkgJson, MAX_GEMINI_CLI_PACKAGE_JSON_BYTES) + const pkg = JSON.parse(buffer.toString('utf8')) as { name?: string } + if (pkg.name === '@google/gemini-cli') { + return current } + } catch { + // Missing, malformed, or oversized package.json — keep walking. } // Global Node layout: /lib/node_modules/@google/gemini-cli @@ -245,14 +304,14 @@ export async function extractOAuthClientCredentials(): Promise<{ const packageRoot = await findGeminiPackageRoot(realPath) if (packageRoot) { const fromSource = - (await tryReadCredentials( + (await readGeminiOAuthCredentialsFile( path.join(packageRoot, 'node_modules', '@google', 'gemini-cli-core', OAUTH2_SUBPATH) - )) ?? (await tryReadCredentials(path.join(packageRoot, OAUTH2_SUBPATH))) + )) ?? (await readGeminiOAuthCredentialsFile(path.join(packageRoot, OAUTH2_SUBPATH))) if (fromSource) { return fromSource } - const fromBundle = await extractFromBundleDir(packageRoot) + const fromBundle = await extractGeminiOAuthCredentialsFromBundleDir(packageRoot) if (fromBundle) { return fromBundle } diff --git a/src/main/rate-limits/gemini-oauth-sources.ts b/src/main/rate-limits/gemini-oauth-sources.ts index 386c8b281f3..b28f4383b29 100644 --- a/src/main/rate-limits/gemini-oauth-sources.ts +++ b/src/main/rate-limits/gemini-oauth-sources.ts @@ -1,8 +1,10 @@ -import { readFile, writeFile, rename } from 'node:fs/promises' +import { writeFile, rename } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' import { net } from 'electron' import { extractOAuthClientCredentials } from './gemini-cli-oauth-extractor' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' +import { readIntegrationCredentialFileText } from '../integration-credential-file' const API_TIMEOUT_MS = 10_000 const OAUTH_CREDS_PATH = path.join(homedir(), '.gemini', 'oauth_creds.json') @@ -39,7 +41,7 @@ export async function readAuthJson(): Promise { for (const candidate of candidates) { try { - const raw = await readFile(candidate, 'utf-8') + const raw = await readIntegrationCredentialFileText(candidate) return JSON.parse(raw) as AuthJson } catch (err) { if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { @@ -54,7 +56,7 @@ export async function readAuthJson(): Promise { export async function readGeminiCredentials(): Promise { try { - const raw = await readFile(OAUTH_CREDS_PATH, 'utf-8') + const raw = await readIntegrationCredentialFileText(OAUTH_CREDS_PATH) const parsed = JSON.parse(raw) as unknown if ( parsed && @@ -110,11 +112,11 @@ export async function refreshAccessToken( return { accessToken: null, newRefreshToken: null } } - const data = (await res.json()) as { + const data = await readFetchResponseJsonWithinLimit<{ access_token?: string refresh_token?: string expires_in?: number - } + }>(res) return { accessToken: typeof data.access_token === 'string' ? data.access_token : null, newRefreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : null, @@ -137,7 +139,7 @@ export async function loadProjectId(accessToken: string): Promise { throw new Error(`Failed to load Gemini project ID (HTTP ${res.status})`) } - const data = (await res.json()) as { cloudaicompanionProject?: string } + const data = await readFetchResponseJsonWithinLimit<{ cloudaicompanionProject?: string }>(res) if (typeof data.cloudaicompanionProject !== 'string') { throw new Error('Gemini project ID not found in API response') } diff --git a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts index fddd0a77d59..b2acf274e70 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts @@ -23,13 +23,15 @@ vi.mock('./gemini-cli-oauth-extractor', () => ({ })) vi.mock('node:fs/promises', () => ({ - readFile: readFileMock, // Why: saveGeminiCredentials is exercised on the refresh path. The atomic // tmp+rename write has no observable side effect in these tests, so the // stubs just resolve. writeFile: vi.fn().mockResolvedValue(undefined), rename: vi.fn().mockResolvedValue(undefined) })) +vi.mock('../integration-credential-file', () => ({ + readIntegrationCredentialFileText: readFileMock +})) vi.mock('electron', () => ({ net: { fetch: netFetchMock } diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts index e8415c2e07e..16f90a338f8 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.test.ts @@ -22,10 +22,12 @@ vi.mock('./gemini-cli-oauth-extractor', () => ({ })) vi.mock('node:fs/promises', () => ({ - readFile: readFileMock, writeFile: vi.fn().mockResolvedValue(undefined), rename: vi.fn().mockResolvedValue(undefined) })) +vi.mock('../integration-credential-file', () => ({ + readIntegrationCredentialFileText: readFileMock +})) vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) import { fetchGeminiRateLimits } from './gemini-usage-fetcher' diff --git a/src/main/rate-limits/gemini-usage-fetcher.ts b/src/main/rate-limits/gemini-usage-fetcher.ts index 1f214b4b5eb..d437456d8c0 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.ts @@ -1,5 +1,6 @@ import { net } from 'electron' import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' import { loadProjectId, readAuthJson, @@ -63,7 +64,7 @@ async function fetchQuota(accessToken: string, projectId: string): Promise(res) const buckets = deduplicateBuckets( parseQuotaResponse(data).map((b) => ({ ...buildRateLimitBucket(b), modelId: b.modelId })) ) diff --git a/src/main/rate-limits/grok-auth.test.ts b/src/main/rate-limits/grok-auth.test.ts index 7bf8f9e62b3..3c133a6bb73 100644 --- a/src/main/rate-limits/grok-auth.test.ts +++ b/src/main/rate-limits/grok-auth.test.ts @@ -1,21 +1,40 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const authFile = vi.hoisted<{ contents: string | null; error: Error | null }>(() => ({ + contents: null, + error: null +})) + +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + existsSync: vi.fn(() => authFile.contents !== null || authFile.error !== null) +})) + +vi.mock('../integration-credential-file', () => ({ + readIntegrationCredentialFileSyncText: vi.fn(() => { + if (authFile.error) { + throw authFile.error + } + if (authFile.contents === null) { + throw new Error('Auth file is missing') + } + return authFile.contents + }) +})) + +import { readGrokAuthSession } from './grok-auth' describe('readGrokAuthSession', () => { - afterEach(() => { - vi.resetModules() - vi.doUnmock('node:fs') + beforeEach(() => { + authFile.contents = null + authFile.error = null }) - it('redacts filesystem paths from auth read failures', async () => { - vi.doMock('node:fs', () => ({ - existsSync: vi.fn(() => true), - readFileSync: vi.fn(() => { - throw new Error( - 'EACCES: permission denied, open /Users/brennanbenson/private/.grok/auth.json' - ) - }) - })) - const { readGrokAuthSession } = await import('./grok-auth') + it('redacts filesystem paths from auth read failures', () => { + authFile.error = new Error( + 'EACCES: permission denied, open /Users/brennanbenson/private/.grok/auth.json' + ) expect(readGrokAuthSession()).toEqual({ status: 'error', @@ -23,22 +42,16 @@ describe('readGrokAuthSession', () => { }) }) - it('treats a token-less auth file as signed out, not an error', async () => { - vi.doMock('node:fs', () => ({ - existsSync: vi.fn(() => true), - readFileSync: vi.fn(() => JSON.stringify({ 'https://auth.x.ai::client': { user_id: 'u1' } })) - })) - const { readGrokAuthSession } = await import('./grok-auth') + it('treats a token-less auth file as signed out, not an error', () => { + authFile.contents = JSON.stringify({ + 'https://auth.x.ai::client': { user_id: 'u1' } + }) expect(readGrokAuthSession()).toEqual({ status: 'missing' }) }) - it('reports malformed auth JSON without parser details', async () => { - vi.doMock('node:fs', () => ({ - existsSync: vi.fn(() => true), - readFileSync: vi.fn(() => '{') - })) - const { readGrokAuthSession } = await import('./grok-auth') + it('reports malformed auth JSON without parser details', () => { + authFile.contents = '{' expect(readGrokAuthSession()).toEqual({ status: 'error', @@ -48,29 +61,23 @@ describe('readGrokAuthSession', () => { it.each(['https://auth.x.ai', 'https://auth.x.ai::client'])( 'prefers the %s issuer entry over an earlier alternate issuer', - async (preferredIssuer) => { - vi.doMock('node:fs', () => ({ - existsSync: vi.fn(() => true), - readFileSync: vi.fn(() => - JSON.stringify({ - 'https://stale.example.com::client': { - key: 'stale-token', - user_id: 'stale-user', - email: 'stale@example.com', - expires_at: '2099-01-01T00:00:00.000Z' - }, - [preferredIssuer]: { - key: 'live-token', - user_id: 'live-user', - email: 'live@example.com', - team_id: 'team-1', - expires_at: '2099-06-01T00:00:00.000Z', - oidc_client_id: 'client-1' - } - }) - ) - })) - const { readGrokAuthSession } = await import('./grok-auth') + (preferredIssuer) => { + authFile.contents = JSON.stringify({ + 'https://stale.example.com::client': { + key: 'stale-token', + user_id: 'stale-user', + email: 'stale@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + }, + [preferredIssuer]: { + key: 'live-token', + user_id: 'live-user', + email: 'live@example.com', + team_id: 'team-1', + expires_at: '2099-06-01T00:00:00.000Z', + oidc_client_id: 'client-1' + } + }) expect(readGrokAuthSession()).toEqual({ status: 'ok', @@ -86,21 +93,15 @@ describe('readGrokAuthSession', () => { } ) - it('falls back to the first tokenized entry when no auth.x.ai key exists', async () => { - vi.doMock('node:fs', () => ({ - existsSync: vi.fn(() => true), - readFileSync: vi.fn(() => - JSON.stringify({ - 'https://alternate.example.com::client': { - key: 'alt-token', - user_id: 'alt-user', - email: 'alt@example.com', - expires_at: '2099-01-01T00:00:00.000Z' - } - }) - ) - })) - const { readGrokAuthSession } = await import('./grok-auth') + it('falls back to the first tokenized entry when no auth.x.ai key exists', () => { + authFile.contents = JSON.stringify({ + 'https://alternate.example.com::client': { + key: 'alt-token', + user_id: 'alt-user', + email: 'alt@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) expect(readGrokAuthSession()).toEqual({ status: 'ok', @@ -115,23 +116,17 @@ describe('readGrokAuthSession', () => { }) }) - it('skips an expired auth.x.ai client entry when a fresh one follows it', async () => { - vi.doMock('node:fs', () => ({ - existsSync: vi.fn(() => true), - readFileSync: vi.fn(() => - JSON.stringify({ - 'https://auth.x.ai::old-client': { - key: 'expired-token', - expires_at: '2020-01-01T00:00:00.000Z' - }, - 'https://auth.x.ai::current-client': { - key: 'fresh-token', - expires_at: '2099-01-01T00:00:00.000Z' - } - }) - ) - })) - const { readGrokAuthSession } = await import('./grok-auth') + it('skips an expired auth.x.ai client entry when a fresh one follows it', () => { + authFile.contents = JSON.stringify({ + 'https://auth.x.ai::old-client': { + key: 'expired-token', + expires_at: '2020-01-01T00:00:00.000Z' + }, + 'https://auth.x.ai::current-client': { + key: 'fresh-token', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) expect(readGrokAuthSession()).toMatchObject({ status: 'ok', @@ -139,17 +134,11 @@ describe('readGrokAuthSession', () => { }) }) - it('does not resurrect an alternate issuer when an auth.x.ai entry is tokenless', async () => { - vi.doMock('node:fs', () => ({ - existsSync: vi.fn(() => true), - readFileSync: vi.fn(() => - JSON.stringify({ - 'https://alternate.example.com::client': { key: 'stale-token' }, - 'https://auth.x.ai::client': { user_id: 'signed-out-user' } - }) - ) - })) - const { readGrokAuthSession } = await import('./grok-auth') + it('does not resurrect an alternate issuer when an auth.x.ai entry is tokenless', () => { + authFile.contents = JSON.stringify({ + 'https://alternate.example.com::client': { key: 'stale-token' }, + 'https://auth.x.ai::client': { user_id: 'signed-out-user' } + }) expect(readGrokAuthSession()).toEqual({ status: 'missing' }) }) diff --git a/src/main/rate-limits/grok-auth.ts b/src/main/rate-limits/grok-auth.ts index e055c022975..5f2d5708121 100644 --- a/src/main/rate-limits/grok-auth.ts +++ b/src/main/rate-limits/grok-auth.ts @@ -1,6 +1,7 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { join } from 'node:path' import { resolveGrokHomeDir } from '../../shared/grok-session-paths' +import { readIntegrationCredentialFileSyncText } from '../integration-credential-file' // Why: when GROK_HOME is set, auth.json must be the same path Grok CLI uses. export function getGrokHome(): string { @@ -88,7 +89,7 @@ export function readGrokAuthSession(): GrokAuthReadResult { return { status: 'missing' } } try { - const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')) + const parsed: unknown = JSON.parse(readIntegrationCredentialFileSyncText(path)) if (typeof parsed !== 'object' || parsed === null) { return { status: 'error', error: 'Grok auth file is invalid' } } diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index 63d7bc5cfcd..115620352a3 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -11,8 +11,11 @@ vi.mock('electron', () => ({ })) vi.mock('node:fs', () => ({ - existsSync: () => authState.file !== null, - readFileSync: () => { + existsSync: () => authState.file !== null +})) + +vi.mock('../integration-credential-file', () => ({ + readIntegrationCredentialFileSyncText: () => { if (authState.readError) { throw authState.readError } diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index 366c75c3835..cd50412b38c 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -10,6 +10,7 @@ import { type GrokAuthReadResult, type GrokAuthSession } from './grok-auth' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' // Why: billing URL and headers must match Grok CLI or xAI rejects the request. const GROK_CLI_PROXY_BASE = @@ -211,7 +212,7 @@ async function fetchBillingData( result: result('error', `Grok usage request failed (HTTP ${res.status})`) } } - const data: unknown = await res.json() + const data = await readFetchResponseJsonWithinLimit(res) return { kind: 'data', data: typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {} diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts index 43e8453fa0f..dc6e9a75e6b 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanupHiddenRateLimitPty, getActiveHiddenRateLimitPtyCount, + HiddenRateLimitPtyCapacityError, + MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS, registerHiddenRateLimitPty } from './hidden-pty-cleanup' @@ -132,4 +134,29 @@ describe('cleanupHiddenRateLimitPty', () => { expect(getActiveHiddenRateLimitPtyCount()).toBe(0) }) + + it('caps active hidden PTYs, tears down overflow, and recovers after disposal', () => { + setPlatform('darwin') + const registrations = Array.from({ length: MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS }, () => + registerHiddenRateLimitPty({ kill: vi.fn(), destroy: vi.fn() }) + ) + expect(getActiveHiddenRateLimitPtyCount()).toBe(MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS) + + const overflowKill = vi.fn() + const overflow = { kill: overflowKill, destroy: vi.fn() } + expect(() => registerHiddenRateLimitPty(overflow)).toThrow(HiddenRateLimitPtyCapacityError) + expect(overflowKill).toHaveBeenCalledOnce() + expect(overflow.destroy).toHaveBeenCalledOnce() + expect(getActiveHiddenRateLimitPtyCount()).toBe(MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS) + + registrations[0]!.dispose() + const recovered = registerHiddenRateLimitPty({ kill: vi.fn(), destroy: vi.fn() }) + expect(getActiveHiddenRateLimitPtyCount()).toBe(MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS) + + for (const registration of registrations.slice(1)) { + registration.dispose() + } + recovered.dispose() + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) }) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts index 068b937ee84..0b95f85b83f 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -7,9 +7,24 @@ type Disposable = { dispose: () => void } +export const MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS = 16 const activeHiddenRateLimitPtys = new Set() +export class HiddenRateLimitPtyCapacityError extends Error { + constructor() { + super(`Hidden rate-limit PTY capacity exceeds ${MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS}`) + this.name = 'HiddenRateLimitPtyCapacityError' + } +} + export function registerHiddenRateLimitPty(term: HiddenPty): Disposable { + if ( + !activeHiddenRateLimitPtys.has(term) && + activeHiddenRateLimitPtys.size >= MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS + ) { + cleanupHiddenRateLimitPty(term, [], { kill: true }) + throw new HiddenRateLimitPtyCapacityError() + } activeHiddenRateLimitPtys.add(term) return { dispose: () => { diff --git a/src/main/rate-limits/kimi-fetcher.test.ts b/src/main/rate-limits/kimi-fetcher.test.ts index 58f11dfdb03..2ef7a137996 100644 --- a/src/main/rate-limits/kimi-fetcher.test.ts +++ b/src/main/rate-limits/kimi-fetcher.test.ts @@ -12,7 +12,12 @@ vi.mock('electron', () => ({ vi.mock('node:fs', () => ({ existsSync: () => fsState.credentials !== null, - readFileSync: () => { + writeFileSync: () => {}, + renameSync: () => {} +})) + +vi.mock('../integration-credential-file', () => ({ + readIntegrationCredentialFileSyncText: () => { if (fsState.readError) { throw fsState.readError } @@ -20,9 +25,7 @@ vi.mock('node:fs', () => ({ throw new Error('ENOENT') } return fsState.credentials - }, - writeFileSync: () => {}, - renameSync: () => {} + } })) vi.mock('node:os', () => ({ homedir: () => '/home/test' })) diff --git a/src/main/rate-limits/kimi-fetcher.ts b/src/main/rate-limits/kimi-fetcher.ts index 778b90b796c..29321b69fc5 100644 --- a/src/main/rate-limits/kimi-fetcher.ts +++ b/src/main/rate-limits/kimi-fetcher.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { net } from 'electron' @@ -7,6 +7,8 @@ import type { RateLimitWindow, UsageRateLimitMetadata } from '../../shared/rate-limit-types' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' +import { readIntegrationCredentialFileSyncText } from '../integration-credential-file' // Why: Kimi Code's managed coding plan exposes subscription usage at // `${base}/usages` (see packages/oauth/src/managed-usage.ts in the CLI bundle). @@ -58,7 +60,7 @@ function readCredentials(): CredentialsReadResult { return { status: 'missing' } } try { - const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')) + const parsed: unknown = JSON.parse(readIntegrationCredentialFileSyncText(path)) const credentials = parseCredentials(parsed) return credentials ? { status: 'ok', credentials } @@ -275,7 +277,7 @@ export async function fetchKimiRateLimits(): Promise { if (!res.ok) { return result('error', `Kimi usage request failed (HTTP ${res.status})`) } - const data: unknown = await res.json() + const data = await readFetchResponseJsonWithinLimit(res) return mapUsageResponse(typeof data === 'object' && data !== null ? data : {}) } catch (err) { return result('error', err instanceof Error ? err.message : 'Kimi usage request failed') diff --git a/src/main/rate-limits/minimax-fetcher.ts b/src/main/rate-limits/minimax-fetcher.ts index 20348feee72..3d26b799cf9 100644 --- a/src/main/rate-limits/minimax-fetcher.ts +++ b/src/main/rate-limits/minimax-fetcher.ts @@ -1,4 +1,5 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' import { extractMiniMaxCookieValue, fetchMiniMaxWithManualCookieHeader, @@ -244,7 +245,7 @@ export async function fetchMiniMaxRateLimits( } let payload: MiniMaxUsageResponse try { - payload = (await fetchResult.response.json()) as MiniMaxUsageResponse + payload = await readFetchResponseJsonWithinLimit(fetchResult.response) } catch (error) { const message = error instanceof Error ? error.message : 'Invalid MiniMax usage response' return makeError(redactMiniMaxSecret(message), 'parse') diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index 934d1e72b42..b0713507dbd 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -8,6 +8,7 @@ import { OPENCODE_BASE_URL } from './opencode-go-request-session' import { parseSubscriptionFromPageText } from './opencode-go-page-scraper' +import { readFetchResponseTextWithinLimit } from '../lib/fetch-response-body' const OPENCODE_SERVER_URL = 'https://opencode.ai/_server' const API_TIMEOUT_MS = 15_000 @@ -201,7 +202,7 @@ async function fetchOpenCodeGoRateLimitsWithSession( } } - const workspacesText = await workspacesRes.text() + const workspacesText = await readFetchResponseTextWithinLimit(workspacesRes) ids = parseWorkspaceIds(workspacesText) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' @@ -251,7 +252,7 @@ async function fetchOpenCodeGoRateLimitsWithSession( continue } - const pageText = await pageRes.text() + const pageText = await readFetchResponseTextWithinLimit(pageRes) const parsed = parseSubscriptionFromPageText(pageText) if (parsed) { const monthly = diff --git a/src/main/rate-limits/rate-limit-pty-output-tail.test.ts b/src/main/rate-limits/rate-limit-pty-output-tail.test.ts new file mode 100644 index 00000000000..672c890b493 --- /dev/null +++ b/src/main/rate-limits/rate-limit-pty-output-tail.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { appendRateLimitPtyOutputTail } from './rate-limit-pty-output-tail' + +describe('appendRateLimitPtyOutputTail', () => { + it('preserves ordinary output exactly', () => { + const first = appendRateLimitPtyOutputTail('', 'first', 16) + const second = appendRateLimitPtyOutputTail(first.output, ' second', 16) + + expect(second).toEqual({ + output: 'first second', + scannedChunk: ' second' + }) + }) + + it('keeps the exact newest characters across chunks', () => { + const first = appendRateLimitPtyOutputTail('', '123456', 8) + const second = appendRateLimitPtyOutputTail(first.output, '789tail', 8) + + expect(second.output).toBe('6789tail') + }) + + it('copies only a bounded suffix from one oversized PTY chunk', () => { + const result = appendRateLimitPtyOutputTail('old', `HEAD${'x'.repeat(1_000_000)}TAIL`, 16) + + expect(result.output).toHaveLength(16) + expect(result.output).toBe(result.scannedChunk) + expect(result.output).toBe(`${'x'.repeat(12)}TAIL`) + expect(result.output).not.toContain('HEAD') + }) + + it('preserves UTF-16 code units at the retained boundary', () => { + const result = appendRateLimitPtyOutputTail('', `${'x'.repeat(20)}😀`, 2) + + expect(result.output).toBe('😀') + }) +}) diff --git a/src/main/rate-limits/rate-limit-pty-output-tail.ts b/src/main/rate-limits/rate-limit-pty-output-tail.ts new file mode 100644 index 00000000000..2e3a608aeb2 --- /dev/null +++ b/src/main/rate-limits/rate-limit-pty-output-tail.ts @@ -0,0 +1,33 @@ +export type RateLimitPtyOutputAppend = { + output: string + scannedChunk: string +} + +export function appendRateLimitPtyOutputTail( + existing: string, + chunk: string, + maxChars: number +): RateLimitPtyOutputAppend { + if (!Number.isSafeInteger(maxChars) || maxChars < 0) { + throw new RangeError('Rate-limit PTY output limit must be a non-negative safe integer') + } + if (maxChars === 0) { + return { output: '', scannedChunk: '' } + } + + const scannedChunk = chunk.length > maxChars ? copyUtf16Suffix(chunk, maxChars) : chunk + if (scannedChunk.length >= maxChars) { + return { output: scannedChunk, scannedChunk } + } + const existingBudget = maxChars - scannedChunk.length + const existingTail = existing.length > existingBudget ? existing.slice(-existingBudget) : existing + return { + output: `${existingTail}${scannedChunk}`, + scannedChunk + } +} + +function copyUtf16Suffix(value: string, maxChars: number): string { + // Why: a sliced string may retain the oversized PTY chunk's backing store; this bounded round trip detaches it. + return Buffer.from(value.slice(-maxChars), 'utf16le').toString('utf16le') +} diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts index 226d689a537..92770a09086 100644 --- a/src/main/remote-agent-trust-presets.test.ts +++ b/src/main/remote-agent-trust-presets.test.ts @@ -14,6 +14,7 @@ vi.mock('./providers/ssh-filesystem-dispatch', () => ({ })) const { markRemoteAgentWorkspaceTrusted } = await import('./remote-agent-trust-presets') +const { MAX_AGENT_STATE_FILE_BYTES } = await import('./agent-state-file-reader') function makeFsProvider(overrides: Record = {}) { return { @@ -144,6 +145,27 @@ describe('markRemoteAgentWorkspaceTrusted', () => { }) }) + it('rejects oversized remote config without overwriting it', async () => { + const fsProvider = makeFsProvider({ + readFile: vi.fn(async () => ({ + content: 'x'.repeat(MAX_AGENT_STATE_FILE_BYTES + 1), + isBinary: false + })) + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await expect( + markRemoteAgentWorkspaceTrusted({ + preset: 'codex', + connectionId: 'ssh-1', + workspacePath: '/repo/worktree' + }) + ).rejects.toThrow('Remote agent state exceeds') + + expect(fsProvider.createDir).not.toHaveBeenCalled() + expect(fsProvider.writeFile).not.toHaveBeenCalled() + }) + it('does nothing when the SSH home cannot be resolved safely', async () => { const fsProvider = makeFsProvider() mocks.getActiveMultiplexer.mockReturnValue({ diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts index a88aaceae58..d7ccf96a975 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -7,6 +7,14 @@ import { isWindowsAbsolutePathLike, normalizeRuntimePathSeparators } from '../shared/cross-platform-path' +import { MAX_AGENT_STATE_FILE_BYTES } from './agent-state-file-reader' + +class RemoteAgentStateFileTooLargeError extends Error { + constructor() { + super(`Remote agent state exceeds ${MAX_AGENT_STATE_FILE_BYTES} bytes`) + this.name = 'RemoteAgentStateFileTooLargeError' + } +} export async function markRemoteAgentWorkspaceTrusted(args: { preset: AgentTrustPreset @@ -69,12 +77,27 @@ async function readRemoteTextFile( ): Promise { try { const result = await fsProvider.readFile(filePath) - return result.isBinary ? '' : result.content - } catch { + if (result.isBinary) { + return '' + } + if (Buffer.byteLength(result.content, 'utf8') > MAX_AGENT_STATE_FILE_BYTES) { + throw new RemoteAgentStateFileTooLargeError() + } + return result.content + } catch (error) { + if (error instanceof RemoteAgentStateFileTooLargeError) { + throw error + } return '' } } +function assertRemoteAgentStateWithinLimit(content: string): void { + if (Buffer.byteLength(content, 'utf8') > MAX_AGENT_STATE_FILE_BYTES) { + throw new RemoteAgentStateFileTooLargeError() + } +} + async function markRemoteCodexProjectTrusted( fsProvider: IFilesystemProvider, remoteHome: string, @@ -91,6 +114,7 @@ async function markRemoteCodexProjectTrusted( if (updated === existing) { return } + assertRemoteAgentStateWithinLimit(updated) await fsProvider.createDir(codexDir) await fsProvider.writeFile(configPath, updated) } @@ -113,10 +137,9 @@ async function markRemoteCursorWorkspaceTrusted( // Missing marker: write the same shape the local trust preset writes. } await fsProvider.createDir(trustDir) - await fsProvider.writeFile( - trustFile, - `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` - ) + const content = `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` + assertRemoteAgentStateWithinLimit(content) + await fsProvider.writeFile(trustFile, content) } async function markRemoteCopilotFolderTrusted( @@ -143,6 +166,8 @@ async function markRemoteCopilotFolderTrusted( return } config.trustedFolders = [...existing.filter((entry) => typeof entry === 'string'), workspacePath] + const content = `${JSON.stringify(config, null, 2)}\n` + assertRemoteAgentStateWithinLimit(content) await fsProvider.createDir(configDir) - await fsProvider.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`) + await fsProvider.writeFile(configPath, content) } diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index 81a8d2dbd55..4312aabd27d 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -1,15 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { GitRemoteIdentity } from '../shared/git-remote-identity' import type { Repo } from '../shared/types' -import { detectGitRemoteIdentity } from './repo-git-remote-identity' +import { type GitRemoteIdentityProbe, probeGitRemoteIdentity } from './repo-git-remote-identity' import { enrichMissingRepoGitRemoteIdentities, flushRepoGitRemoteIdentityEnrichmentForTests, + getRepoGitRemoteIdentityNegativeCacheSizeForTests, + REPO_LOCATION_CACHE_KEY_MAX_BYTES, + REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES, resetRepoGitRemoteIdentityEnrichmentForTests } from './repo-git-remote-identity-enrichment' vi.mock('./repo-git-remote-identity', () => ({ - detectGitRemoteIdentity: vi.fn() + probeGitRemoteIdentity: vi.fn() })) type RepoIdentityStore = { @@ -24,6 +27,8 @@ const remoteIdentity: GitRemoteIdentity = { remoteUrl: 'git@git.company.test:team/sample-app.git' } +const resolvedProbe: GitRemoteIdentityProbe = { status: 'resolved', identity: remoteIdentity } + function makeRepo(overrides: Partial = {}): Repo { return { id: 'repo-1', @@ -71,7 +76,7 @@ afterEach(() => { describe('enrichMissingRepoGitRemoteIdentities', () => { it('schedules remote identity enrichment without blocking the caller', async () => { - vi.mocked(detectGitRemoteIdentity).mockResolvedValue(remoteIdentity) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) const repo = makeRepo() const store = makeStore(repo) const onChanged = vi.fn() @@ -79,7 +84,7 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged }) expect(repo.gitRemoteIdentity).toBeUndefined() - expect(detectGitRemoteIdentity).toHaveBeenCalledWith('/workspace/sample-app', undefined) + expect(probeGitRemoteIdentity).toHaveBeenCalledWith('/workspace/sample-app', undefined) await flushRepoGitRemoteIdentityEnrichmentForTests() @@ -88,17 +93,17 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { }) it('coalesces concurrent probes for the same repo location', async () => { - const probe = deferred() - vi.mocked(detectGitRemoteIdentity).mockReturnValue(probe.promise) + const probe = deferred() + vi.mocked(probeGitRemoteIdentity).mockReturnValue(probe.promise) const repo = makeRepo() const store = makeStore(repo) enrichMissingRepoGitRemoteIdentities(store) enrichMissingRepoGitRemoteIdentities(store) - expect(detectGitRemoteIdentity).toHaveBeenCalledTimes(1) + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(1) - probe.resolve(remoteIdentity) + probe.resolve(resolvedProbe) await flushRepoGitRemoteIdentityEnrichmentForTests() expect(store.updateRepo).toHaveBeenCalledTimes(1) @@ -108,7 +113,7 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { it('caches no-identity probes briefly so list calls do not retry every time', async () => { vi.useFakeTimers() vi.setSystemTime(1_000) - vi.mocked(detectGitRemoteIdentity).mockResolvedValue(null) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'no-remote' }) const repo = makeRepo() const store = makeStore(repo) @@ -117,18 +122,102 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store) await flushRepoGitRemoteIdentityEnrichmentForTests() - expect(detectGitRemoteIdentity).toHaveBeenCalledTimes(1) + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(1) + }) + + it('settles a repo git answered for but that has no usable remote', async () => { + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'no-remote' }) + const repo = makeRepo() + const store = makeStore(repo) + + enrichMissingRepoGitRemoteIdentities(store) + await flushRepoGitRemoteIdentityEnrichmentForTests() + + expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: null }) + expect(repo.gitRemoteIdentity).toBeNull() + }) + + it('leaves identity unresolved when the probe could not reach the host', async () => { + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'unavailable' }) + const repo = makeRepo({ connectionId: 'builder' }) + const store = makeStore(repo) + + enrichMissingRepoGitRemoteIdentities(store) + await flushRepoGitRemoteIdentityEnrichmentForTests() + + expect(store.updateRepo).not.toHaveBeenCalled() + expect(repo.gitRemoteIdentity).toBeUndefined() + }) + + it('does not rewrite the no-remote marker on a later retry', async () => { + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'no-remote' }) + const repo = makeRepo({ gitRemoteIdentity: null }) + const store = makeStore(repo) + + enrichMissingRepoGitRemoteIdentities(store) + await flushRepoGitRemoteIdentityEnrichmentForTests() + + expect(store.updateRepo).not.toHaveBeenCalled() + }) + + it('resolves a settled no-remote repo once it gains a remote', async () => { + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) + const repo = makeRepo({ gitRemoteIdentity: null }) + const store = makeStore(repo) + + enrichMissingRepoGitRemoteIdentities(store) + await flushRepoGitRemoteIdentityEnrichmentForTests() + + expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: remoteIdentity }) + }) + + it('bounds negative results across churned repo locations', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'no-remote' }) + const repo = makeRepo() + const store = makeStore(repo) + + for (let index = 0; index <= REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES; index++) { + repo.path = `/workspace/repo-${index}` + enrichMissingRepoGitRemoteIdentities(store) + await flushRepoGitRemoteIdentityEnrichmentForTests() + } + + expect(getRepoGitRemoteIdentityNegativeCacheSizeForTests()).toBe( + REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES + ) + repo.path = '/workspace/repo-0' + enrichMissingRepoGitRemoteIdentities(store) + await flushRepoGitRemoteIdentityEnrichmentForTests() + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes( + REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES + 2 + ) + }) + + it('does not retain oversized repo-location keys', async () => { + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'no-remote' }) + const repo = makeRepo({ path: `/${'x'.repeat(REPO_LOCATION_CACHE_KEY_MAX_BYTES)}` }) + const store = makeStore(repo) + + enrichMissingRepoGitRemoteIdentities(store) + await flushRepoGitRemoteIdentityEnrichmentForTests() + enrichMissingRepoGitRemoteIdentities(store) + await flushRepoGitRemoteIdentityEnrichmentForTests() + + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(2) + expect(getRepoGitRemoteIdentityNegativeCacheSizeForTests()).toBe(0) }) it('does not write stale identity data after the repo path changes', async () => { - const probe = deferred() - vi.mocked(detectGitRemoteIdentity).mockReturnValue(probe.promise) + const probe = deferred() + vi.mocked(probeGitRemoteIdentity).mockReturnValue(probe.promise) const repo = makeRepo() const store = makeStore(repo) enrichMissingRepoGitRemoteIdentities(store) repo.path = '/workspace/renamed-sample-app' - probe.resolve(remoteIdentity) + probe.resolve(resolvedProbe) await flushRepoGitRemoteIdentityEnrichmentForTests() expect(store.updateRepo).not.toHaveBeenCalled() diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts index 166b4d0bec8..78f5e2a0428 100644 --- a/src/main/repo-git-remote-identity-enrichment.ts +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -1,7 +1,11 @@ import type { Repo } from '../shared/types' -import { detectGitRemoteIdentity } from './repo-git-remote-identity' +import { probeGitRemoteIdentity } from './repo-git-remote-identity' +import { getRepoLocationCacheKey } from './repo-location-cache-key' + +export { REPO_LOCATION_CACHE_KEY_MAX_BYTES } from './repo-location-cache-key' const NO_IDENTITY_RETRY_TTL_MS = 5 * 60 * 1000 +export const REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES = 512 type RepoIdentityStore = { getRepos(): Repo[] @@ -16,8 +20,25 @@ type EnrichmentOptions = { const inFlightProbesByLocation = new Map>() const noIdentityRetryAfterByLocation = new Map() -function getRepoLocationKey(repo: Pick): string { - return `${repo.connectionId ?? 'local'}\0${repo.path}` +function pruneNoIdentityRetryCache(now: number): void { + for (const [locationKey, retryAfter] of noIdentityRetryAfterByLocation) { + if (retryAfter <= now) { + noIdentityRetryAfterByLocation.delete(locationKey) + } + } + while (noIdentityRetryAfterByLocation.size > REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES) { + const oldestLocation = noIdentityRetryAfterByLocation.keys().next().value + if (oldestLocation === undefined) { + break + } + noIdentityRetryAfterByLocation.delete(oldestLocation) + } +} + +function rememberNoIdentityRetry(locationKey: string, retryAfter: number): void { + noIdentityRetryAfterByLocation.delete(locationKey) + noIdentityRetryAfterByLocation.set(locationKey, retryAfter) + pruneNoIdentityRetryCache(Date.now()) } function getCurrentRepo(store: RepoIdentityStore, id: string): Repo | undefined { @@ -34,37 +55,61 @@ function isSameUnenrichedRepo(snapshot: Repo, current: Repo | undefined): boolea ) } -async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo): Promise { - const locationKey = getRepoLocationKey(repo) - const retryAfter = noIdentityRetryAfterByLocation.get(locationKey) ?? 0 - if (retryAfter > Date.now()) { +function writeIdentity( + store: RepoIdentityStore, + snapshot: Repo, + gitRemoteIdentity: Repo['gitRemoteIdentity'] +): boolean { + const current = getCurrentRepo(store, snapshot.id) + if (!isSameUnenrichedRepo(snapshot, current)) { return false } - const inFlight = inFlightProbesByLocation.get(locationKey) + // Why: the no-remote marker is re-derived on every retry; skip the redundant + // write so repo-list consumers do not churn. + if (gitRemoteIdentity === null && current?.gitRemoteIdentity === null) { + return false + } + return !!store.updateRepo(snapshot.id, { gitRemoteIdentity }) +} + +async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo): Promise { + const locationKey = getRepoLocationCacheKey(repo) + const now = Date.now() + pruneNoIdentityRetryCache(now) + const retryAfter = locationKey ? (noIdentityRetryAfterByLocation.get(locationKey) ?? 0) : 0 + if (retryAfter > now) { + return false + } + const inFlight = locationKey ? inFlightProbesByLocation.get(locationKey) : undefined if (inFlight) { return inFlight } const probe = (async () => { - const identity = await detectGitRemoteIdentity(repo.path, repo.connectionId) - if (!identity) { + const result = await probeGitRemoteIdentity(repo.path, repo.connectionId) + if (result.status !== 'resolved') { // Why: repos without a parseable remote are common; cache misses briefly so // list calls stay cheap while still allowing recent remote changes to land. - noIdentityRetryAfterByLocation.set(locationKey, Date.now() + NO_IDENTITY_RETRY_TTL_MS) - return false + if (locationKey) { + rememberNoIdentityRetry(locationKey, Date.now() + NO_IDENTITY_RETRY_TTL_MS) + } + // Why: only a probe that actually reached git settles "no usable remote". + // An unreachable host leaves the identity unknown so consumers can keep + // treating the repo as pending instead of ineligible. + return result.status === 'no-remote' ? writeIdentity(store, repo, null) : false } - noIdentityRetryAfterByLocation.delete(locationKey) - const current = getCurrentRepo(store, repo.id) - if (!isSameUnenrichedRepo(repo, current)) { - return false + if (locationKey) { + noIdentityRetryAfterByLocation.delete(locationKey) } - return !!store.updateRepo(repo.id, { gitRemoteIdentity: identity }) + return writeIdentity(store, repo, result.identity) })().finally(() => { - if (inFlightProbesByLocation.get(locationKey) === probe) { + if (locationKey && inFlightProbesByLocation.get(locationKey) === probe) { inFlightProbesByLocation.delete(locationKey) } }) - inFlightProbesByLocation.set(locationKey, probe) + if (locationKey) { + inFlightProbesByLocation.set(locationKey, probe) + } return probe } @@ -72,6 +117,10 @@ async function enrichMissingRepoGitRemoteIdentitiesInBackground( store: RepoIdentityStore, options: EnrichmentOptions ): Promise { + // Why: the settled `null` marker stays a candidate on purpose — a repo that + // gains a remote later must still resolve. Do not tighten this to + // `=== undefined`; the retry TTL already bounds the cost and `writeIdentity` + // skips the redundant rewrite. const candidates = store .getRepos() .filter((repo) => repo.kind !== 'folder' && !repo.gitRemoteIdentity) @@ -105,3 +154,7 @@ export function resetRepoGitRemoteIdentityEnrichmentForTests(): void { inFlightProbesByLocation.clear() noIdentityRetryAfterByLocation.clear() } + +export function getRepoGitRemoteIdentityNegativeCacheSizeForTests(): number { + return noIdentityRetryAfterByLocation.size +} diff --git a/src/main/repo-git-remote-identity.test.ts b/src/main/repo-git-remote-identity.test.ts new file mode 100644 index 00000000000..807bcb357c9 --- /dev/null +++ b/src/main/repo-git-remote-identity.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { gitExecFileAsync } from './git/runner' +import { getSshGitProvider } from './providers/ssh-git-dispatch' +import { probeGitRemoteIdentity } from './repo-git-remote-identity' + +vi.mock('./git/runner', () => ({ gitExecFileAsync: vi.fn() })) +vi.mock('./providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) + +const gitlabRemote = 'origin\tgit@gitlab.example.com:team/orca.git (fetch)\n' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('probeGitRemoteIdentity', () => { + it('resolves the canonical identity for a non-GitHub remote', async () => { + vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: gitlabRemote, stderr: '' }) + + await expect(probeGitRemoteIdentity('/repos/orca')).resolves.toEqual({ + status: 'resolved', + identity: { + canonicalKey: 'gitlab.example.com/team/orca', + remoteName: 'origin', + remoteUrl: 'git@gitlab.example.com:team/orca.git' + } + }) + }) + + it('settles on no-remote when git answers with nothing usable', async () => { + vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: '', stderr: '' }) + + await expect(probeGitRemoteIdentity('/repos/orca')).resolves.toEqual({ status: 'no-remote' }) + }) + + it('reports unavailable when the SSH host has no connected git provider', async () => { + vi.mocked(getSshGitProvider).mockReturnValue(undefined) + + await expect(probeGitRemoteIdentity('/repos/orca', 'builder')).resolves.toEqual({ + status: 'unavailable' + }) + }) + + it('reports unavailable when the local git command fails', async () => { + vi.mocked(gitExecFileAsync).mockRejectedValue(new Error('not a git repository')) + + await expect(probeGitRemoteIdentity('/repos/orca')).resolves.toEqual({ status: 'unavailable' }) + }) + + it('reports unavailable when a connected SSH provider cannot reach the host', async () => { + const exec = vi.fn().mockRejectedValue(new Error('ssh: connect to host builder: down')) + vi.mocked(getSshGitProvider).mockReturnValue({ exec } as never) + + await expect(probeGitRemoteIdentity('/repos/orca', 'builder')).resolves.toEqual({ + status: 'unavailable' + }) + expect(exec).toHaveBeenCalledWith(['remote', '-v'], '/repos/orca') + expect(gitExecFileAsync).not.toHaveBeenCalled() + }) + + it('settles on no-remote for an SSH repo git answered for with no remotes', async () => { + vi.mocked(getSshGitProvider).mockReturnValue({ + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + } as never) + + await expect(probeGitRemoteIdentity('/repos/orca', 'builder')).resolves.toEqual({ + status: 'no-remote' + }) + }) +}) diff --git a/src/main/repo-git-remote-identity.ts b/src/main/repo-git-remote-identity.ts index f5ba64a301a..6d517a06c03 100644 --- a/src/main/repo-git-remote-identity.ts +++ b/src/main/repo-git-remote-identity.ts @@ -2,17 +2,37 @@ import { deriveGitRemoteIdentity, type GitRemoteIdentity } from '../shared/git-r import { gitExecFileAsync } from './git/runner' import { getSshGitProvider } from './providers/ssh-git-dispatch' +/** `no-remote` means git answered and the repo has no usable remote; + * `unavailable` means the probe never reached git (host down, SSH not up + * yet, git error) and says nothing about the repo. */ +export type GitRemoteIdentityProbe = + | { status: 'resolved'; identity: GitRemoteIdentity } + | { status: 'no-remote' } + | { status: 'unavailable' } + +export async function probeGitRemoteIdentity( + repoPath: string, + connectionId?: string | null +): Promise { + try { + const result = connectionId + ? await getSshGitProvider(connectionId)?.exec(['remote', '-v'], repoPath) + : await gitExecFileAsync(['remote', '-v'], { cwd: repoPath }) + if (!result) { + return { status: 'unavailable' } + } + const identity = deriveGitRemoteIdentity(result.stdout) + return identity ? { status: 'resolved', identity } : { status: 'no-remote' } + } catch { + // Repo creation must not fail because a best-effort remote probe failed. + return { status: 'unavailable' } + } +} + export async function detectGitRemoteIdentity( repoPath: string, connectionId?: string | null ): Promise { - try { - const result = connectionId - ? await getSshGitProvider(connectionId)?.exec(['remote', '-v'], repoPath) - : await gitExecFileAsync(['remote', '-v'], { cwd: repoPath }) - return result ? deriveGitRemoteIdentity(result.stdout) : null - } catch { - // Repo creation must not fail because a best-effort remote probe failed. - return null - } + const probe = await probeGitRemoteIdentity(repoPath, connectionId) + return probe.status === 'resolved' ? probe.identity : null } diff --git a/src/main/repo-git-username-enrichment.test.ts b/src/main/repo-git-username-enrichment.test.ts index bcecb8a2340..bc48ee2bb8c 100644 --- a/src/main/repo-git-username-enrichment.test.ts +++ b/src/main/repo-git-username-enrichment.test.ts @@ -11,6 +11,9 @@ vi.mock('./git/git-username', () => ({ import { enrichRepoGitUsernames, flushRepoGitUsernameEnrichmentForTests, + getRepoGitUsernameAttemptCountForTests, + REPO_LOCATION_CACHE_KEY_MAX_BYTES, + REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES, resetRepoGitUsernameEnrichmentForTests } from './repo-git-username-enrichment' @@ -82,6 +85,37 @@ describe('enrichRepoGitUsernames', () => { expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(1) }) + it('bounds remembered attempts across churned repo locations', async () => { + const repos = Array.from({ length: REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES + 1 }, (_, index) => + makeRepo({ id: `r${index}`, path: `C:/repos/${index}` }) + ) + const store = makeStore(repos) + + enrichRepoGitUsernames(store) + await flushRepoGitUsernameEnrichmentForTests() + + expect(getRepoGitUsernameAttemptCountForTests()).toBe(REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES) + enrichRepoGitUsernames(store) + await flushRepoGitUsernameEnrichmentForTests() + expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes( + REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES + 2 + ) + }) + + it('does not retain oversized repo-location keys', async () => { + const store = makeStore([ + makeRepo({ path: `C:/${'x'.repeat(REPO_LOCATION_CACHE_KEY_MAX_BYTES)}` }) + ]) + + enrichRepoGitUsernames(store) + await flushRepoGitUsernameEnrichmentForTests() + enrichRepoGitUsernames(store) + await flushRepoGitUsernameEnrichmentForTests() + + expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(2) + expect(getRepoGitUsernameAttemptCountForTests()).toBe(0) + }) + it('keeps persisted usernames on a non-authoritative empty resolution', async () => { resolveLocalGitUsernameDetailedMock.mockResolvedValue(resolved('', false)) const store = makeStore([makeRepo()]) diff --git a/src/main/repo-git-username-enrichment.ts b/src/main/repo-git-username-enrichment.ts index c5ae5c6d7b9..1054e6ae3b5 100644 --- a/src/main/repo-git-username-enrichment.ts +++ b/src/main/repo-git-username-enrichment.ts @@ -1,5 +1,8 @@ import type { Repo } from '../shared/types' import { resolveLocalGitUsernameDetailed } from './git/git-username' +import { getRepoLocationCacheKey } from './repo-location-cache-key' + +export { REPO_LOCATION_CACHE_KEY_MAX_BYTES } from './repo-location-cache-key' type RepoUsernameStore = { getRepos(): Repo[] @@ -10,6 +13,7 @@ type EnrichmentOptions = { onChanged?: () => void } +export const REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES = 512 // Why: resolution spawns git (and possibly gh) subprocesses, so run it at most // once per repo location per app session — hydrateRepo serves the persisted // value in between, and a relaunch picks up config changes. @@ -17,8 +21,16 @@ const attemptedLocations = new Set() let enrichmentInFlight: Promise | null = null let rerunRequested = false -function getRepoLocationKey(repo: Pick): string { - return `${repo.connectionId ?? 'local'}\0${repo.path}` +function rememberAttemptedLocation(locationKey: string): void { + attemptedLocations.delete(locationKey) + attemptedLocations.add(locationKey) + while (attemptedLocations.size > REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES) { + const oldestLocation = attemptedLocations.values().next().value + if (oldestLocation === undefined) { + break + } + attemptedLocations.delete(oldestLocation) + } } async function enrichRepoGitUsernamesInBackground( @@ -31,11 +43,14 @@ async function enrichRepoGitUsernamesInBackground( // Why: SSH repo paths are remote; local git cannot inspect them. The // SSH username path (getSshGitUsername) stays caller-driven. !repo.connectionId && - !attemptedLocations.has(getRepoLocationKey(repo)) + !attemptedLocations.has(getRepoLocationCacheKey(repo) ?? '') ) let changed = false for (const repo of candidates) { - attemptedLocations.add(getRepoLocationKey(repo)) + const locationKey = getRepoLocationCacheKey(repo) + if (locationKey) { + rememberAttemptedLocation(locationKey) + } const { username, authoritative } = await resolveLocalGitUsernameDetailed(repo.path) // Why: a non-authoritative '' means a probe timed out and says nothing // about the account — keep the persisted value. An authoritative result @@ -93,3 +108,7 @@ export function resetRepoGitUsernameEnrichmentForTests(): void { enrichmentInFlight = null rerunRequested = false } + +export function getRepoGitUsernameAttemptCountForTests(): number { + return attemptedLocations.size +} diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index c491f344422..647ebf8359f 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' @@ -10,6 +10,16 @@ const PNG_1X1_BASE64 = const tempDirs: string[] = [] +function pngHeader(width: number, height: number): Buffer { + const bytes = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) + bytes.writeUInt32BE(13, 8) + bytes.write('IHDR', 12, 'ascii') + bytes.writeUInt32BE(width, 16) + bytes.writeUInt32BE(height, 20) + return bytes +} + async function makeTempRepoDir(): Promise { const dir = await mkdtemp(join(tmpdir(), 'orca-repo-icon-')) tempDirs.push(dir) @@ -52,6 +62,13 @@ describe('detectRepoIcon', () => { }) }) + it('ignores a repo-local raster dimension bomb', async () => { + const repoPath = await makeTempRepoDir() + await writeFile(join(repoPath, 'favicon.png'), pngHeader(32_769, 1)) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toBeUndefined() + }) + it('resolves declared icon hrefs from project source files', async () => { const repoPath = await makeTempRepoDir() await writeFile(join(repoPath, 'index.html'), '') @@ -91,10 +108,9 @@ describe('detectRepoIcon', () => { it('skips oversized source files when looking for declared icon hrefs', async () => { const repoPath = await makeTempRepoDir() - await writeFile( - join(repoPath, 'index.html'), - `${'x'.repeat(256 * 1024 + 1)}` - ) + const sourcePath = join(repoPath, 'index.html') + await writeFile(sourcePath, '') + await truncate(sourcePath, 256 * 1024 + 1) await mkdir(join(repoPath, 'public', 'brand'), { recursive: true }) await writeFile( join(repoPath, 'public', 'brand', 'icon.png'), diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 233721a8086..e6d845042cc 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -1,50 +1,13 @@ -import { readFile, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' import type { GitHubRepositoryIdentity, RepoKind } from '../shared/types' -import { - faviconUrlFromWebsite, - githubAvatarIcon, - MAX_REPO_ICON_UPLOAD_BYTES, - type RepoIcon -} from '../shared/repo-icon' +import { faviconUrlFromWebsite, githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' import { getRepoSlug, getRepoUpstream } from './github/client' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from './providers/types' import { detectGitRemoteIdentity } from './repo-git-remote-identity' -import { iconHrefCandidates } from './repo-icon-href-candidates' import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' - -const REPO_ICON_FILE_CANDIDATES = [ - 'favicon.png', - 'public/favicon.png', - 'app/favicon.png', - 'app/icon.png', - 'src/favicon.png', - 'src/app/icon.png', - 'assets/favicon.png', - 'assets/icon.png', - 'static/favicon.png', - 'logo.png', - 'public/logo.png' -] - -const REPO_ICON_SOURCE_FILE_CANDIDATES = [ - 'index.html', - 'public/index.html', - 'app/routes/__root.tsx', - 'src/routes/__root.tsx', - 'app/root.tsx', - 'src/root.tsx', - 'src/index.html' -] - -// Why: repo icon detection runs while adding repos; declared-icon probing should -// not read large app entrypoints just to find a small favicon href. -const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024 - -const LINK_ICON_HTML_RE = - /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i -const LINK_ICON_OBJECT_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i +import { readNodeFileWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import { detectLocalRepoPngIcon, detectRemoteRepoPngIcon } from './repo-icon-file-detection' const WEBSITE_HOSTS_TO_SKIP = new Set([ 'github.com', @@ -54,21 +17,6 @@ const WEBSITE_HOSTS_TO_SKIP = new Set([ 'bitbucket.org', 'www.bitbucket.org' ]) - -function isPngBuffer(buffer: Buffer): boolean { - return ( - buffer.length >= 8 && - buffer[0] === 0x89 && - buffer[1] === 0x50 && - buffer[2] === 0x4e && - buffer[3] === 0x47 && - buffer[4] === 0x0d && - buffer[5] === 0x0a && - buffer[6] === 0x1a && - buffer[7] === 0x0a - ) -} - function shouldUseWebsiteFavicon(rawUrl: string): boolean { try { const url = new URL(rawUrl.includes('://') ? rawUrl : `https://${rawUrl}`) @@ -78,140 +26,6 @@ function shouldUseWebsiteFavicon(rawUrl: string): boolean { } } -function extractIconHref(source: string): string | null { - return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null -} - -async function readLocalPngIcon(repoPath: string, relativePath: string): Promise { - const filePath = joinWorktreeRelativePath(repoPath, relativePath) - const info = await stat(filePath) - if (!info.isFile() || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { - return null - } - const buffer = await readFile(filePath) - if (!isPngBuffer(buffer)) { - return null - } - return { - type: 'image', - src: `data:image/png;base64,${buffer.toString('base64')}`, - source: 'file', - label: relativePath - } -} - -async function readRemotePngIcon( - repoPath: string, - fsProvider: IFilesystemProvider, - relativePath: string -): Promise { - const filePath = joinWorktreeRelativePath(repoPath, relativePath) - const info = await fsProvider.stat(filePath) - if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { - return null - } - const result = await fsProvider.readFile(filePath) - if (!result.isBinary || result.mimeType !== 'image/png' || !result.content) { - return null - } - const buffer = Buffer.from(result.content, 'base64') - if (!isPngBuffer(buffer)) { - return null - } - return { - type: 'image', - src: `data:image/png;base64,${buffer.toString('base64')}`, - source: 'file', - label: relativePath - } -} - -async function detectLocalPngIcon(repoPath: string): Promise { - for (const relativePath of REPO_ICON_FILE_CANDIDATES) { - try { - const icon = await readLocalPngIcon(repoPath, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next conventional icon path. - } - } - for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { - try { - const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) - const sourceInfo = await stat(sourcePath) - if (!sourceInfo.isFile() || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { - continue - } - const source = await readFile(sourcePath, 'utf8') - const href = extractIconHref(source) - if (!href) { - continue - } - for (const relativePath of iconHrefCandidates(href, sourceFile)) { - try { - const icon = await readLocalPngIcon(repoPath, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next href resolution. - } - } - } catch { - // Try the next source file. - } - } - return null -} - -async function detectRemotePngIcon( - repoPath: string, - fsProvider: IFilesystemProvider -): Promise { - for (const relativePath of REPO_ICON_FILE_CANDIDATES) { - try { - const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next conventional icon path. - } - } - for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { - try { - const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) - const sourceInfo = await fsProvider.stat(sourcePath) - if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { - continue - } - const result = await fsProvider.readFile(sourcePath) - if (result.isBinary) { - continue - } - const href = extractIconHref(result.content) - if (!href) { - continue - } - for (const relativePath of iconHrefCandidates(href, sourceFile)) { - try { - const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next href resolution. - } - } - } catch { - // Try the next source file. - } - } - return null -} - function packageHomepageIcon(packageJson: unknown): RepoIcon | null { if (!packageJson || typeof packageJson !== 'object') { return null @@ -231,7 +45,11 @@ async function detectLocalPackageHomepageIcon(repoPath: string): Promise 128 * 1024) { return null } - return packageHomepageIcon(JSON.parse(await readFile(packageJsonPath, 'utf8'))) + const packageRead = await readNodeFileWithinLimit(packageJsonPath, 128 * 1024) + if (!packageRead.stats.isFile()) { + return null + } + return packageHomepageIcon(JSON.parse(packageRead.buffer.toString('utf8'))) } catch { return null } @@ -251,6 +69,9 @@ async function detectRemotePackageHomepageIcon( if (result.isBinary) { return null } + if (Buffer.byteLength(result.content, 'utf8') > 128 * 1024) { + return null + } return packageHomepageIcon(JSON.parse(result.content)) } catch { return null @@ -285,8 +106,8 @@ export async function detectRepoIcon({ try { const fsProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined const fileIcon = fsProvider - ? await detectRemotePngIcon(repoPath, fsProvider) - : await detectLocalPngIcon(repoPath) + ? await detectRemoteRepoPngIcon(repoPath, fsProvider) + : await detectLocalRepoPngIcon(repoPath) if (fileIcon) { return fileIcon } diff --git a/src/main/repo-icon-file-detection.ts b/src/main/repo-icon-file-detection.ts new file mode 100644 index 00000000000..e7c74bfb633 --- /dev/null +++ b/src/main/repo-icon-file-detection.ts @@ -0,0 +1,190 @@ +import { stat } from 'node:fs/promises' +import { MAX_REPO_ICON_UPLOAD_BYTES, type RepoIcon } from '../shared/repo-icon' +import { assertRasterImagePreviewWithinLimits } from '../shared/raster-image-preview-limits' +import { readNodeFileWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import type { IFilesystemProvider } from './providers/types' +import { iconHrefCandidates } from './repo-icon-href-candidates' +import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' + +const REPO_ICON_FILE_CANDIDATES = [ + 'favicon.png', + 'public/favicon.png', + 'app/favicon.png', + 'app/icon.png', + 'src/favicon.png', + 'src/app/icon.png', + 'assets/favicon.png', + 'assets/icon.png', + 'static/favicon.png', + 'logo.png', + 'public/logo.png' +] + +const REPO_ICON_SOURCE_FILE_CANDIDATES = [ + 'index.html', + 'public/index.html', + 'app/routes/__root.tsx', + 'src/routes/__root.tsx', + 'app/root.tsx', + 'src/root.tsx', + 'src/index.html' +] + +// Why: repo icon detection runs while adding repos; declared-icon probing should +// not read large app entrypoints just to find a small favicon href. +const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024 + +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i +const LINK_ICON_OBJECT_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i +const PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex') + +function isPngBuffer(buffer: Buffer): boolean { + return buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) +} + +function extractIconHref(source: string): string | null { + return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null +} + +async function readLocalPngIcon(repoPath: string, relativePath: string): Promise { + const filePath = joinWorktreeRelativePath(repoPath, relativePath) + const info = await stat(filePath) + if (!info.isFile() || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return null + } + const { buffer, stats } = await readNodeFileWithinLimit(filePath, MAX_REPO_ICON_UPLOAD_BYTES) + if (!stats.isFile() || !isPngBuffer(buffer)) { + return null + } + assertRasterImagePreviewWithinLimits(buffer, 'image/png') + return { + type: 'image', + src: `data:image/png;base64,${buffer.toString('base64')}`, + source: 'file', + label: relativePath + } +} + +async function readRemotePngIcon( + repoPath: string, + fsProvider: IFilesystemProvider, + relativePath: string +): Promise { + const filePath = joinWorktreeRelativePath(repoPath, relativePath) + const info = await fsProvider.stat(filePath) + if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return null + } + const result = await fsProvider.readFile(filePath) + if (!result.isBinary || result.mimeType !== 'image/png' || !result.content) { + return null + } + if (result.content.length > Math.ceil((MAX_REPO_ICON_UPLOAD_BYTES * 4) / 3) + 4) { + return null + } + const buffer = Buffer.from(result.content, 'base64') + if (buffer.byteLength > MAX_REPO_ICON_UPLOAD_BYTES || !isPngBuffer(buffer)) { + return null + } + assertRasterImagePreviewWithinLimits(buffer, 'image/png') + return { + type: 'image', + src: `data:image/png;base64,${buffer.toString('base64')}`, + source: 'file', + label: relativePath + } +} + +export async function detectLocalRepoPngIcon(repoPath: string): Promise { + for (const relativePath of REPO_ICON_FILE_CANDIDATES) { + try { + const icon = await readLocalPngIcon(repoPath, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next conventional icon path. + } + } + for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { + try { + const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) + const sourceInfo = await stat(sourcePath) + if (!sourceInfo.isFile() || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { + continue + } + const sourceRead = await readNodeFileWithinLimit(sourcePath, MAX_REPO_ICON_SOURCE_BYTES) + if (!sourceRead.stats.isFile()) { + continue + } + const href = extractIconHref(sourceRead.buffer.toString('utf8')) + if (!href) { + continue + } + for (const relativePath of iconHrefCandidates(href, sourceFile)) { + try { + const icon = await readLocalPngIcon(repoPath, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next href resolution. + } + } + } catch { + // Try the next source file. + } + } + return null +} + +export async function detectRemoteRepoPngIcon( + repoPath: string, + fsProvider: IFilesystemProvider +): Promise { + for (const relativePath of REPO_ICON_FILE_CANDIDATES) { + try { + const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next conventional icon path. + } + } + for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { + try { + const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) + const sourceInfo = await fsProvider.stat(sourcePath) + if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { + continue + } + const result = await fsProvider.readFile(sourcePath) + if ( + result.isBinary || + Buffer.byteLength(result.content, 'utf8') > MAX_REPO_ICON_SOURCE_BYTES + ) { + continue + } + const href = extractIconHref(result.content) + if (!href) { + continue + } + for (const relativePath of iconHrefCandidates(href, sourceFile)) { + try { + const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next href resolution. + } + } + } catch { + // Try the next source file. + } + } + return null +} diff --git a/src/main/repo-location-cache-key.ts b/src/main/repo-location-cache-key.ts new file mode 100644 index 00000000000..b9e94e1d64c --- /dev/null +++ b/src/main/repo-location-cache-key.ts @@ -0,0 +1,23 @@ +import { measureUtf8ByteLength } from '../shared/memory-safety/utf8-byte-limits' + +export const REPO_LOCATION_CACHE_KEY_MAX_BYTES = 4 * 1024 + +export function getRepoLocationCacheKey(repo: { + path: string + connectionId?: string | null +}): string | null { + const connectionId = repo.connectionId ?? 'local' + const connectionBytes = measureUtf8ByteLength(connectionId, { + stopAfterBytes: REPO_LOCATION_CACHE_KEY_MAX_BYTES - 1 + }) + if (connectionBytes.exceededLimit) { + return null + } + const pathBytes = measureUtf8ByteLength(repo.path, { + stopAfterBytes: REPO_LOCATION_CACHE_KEY_MAX_BYTES - connectionBytes.byteLength - 1 + }) + if (pathBytes.exceededLimit) { + return null + } + return `${connectionId}\0${repo.path}` +} diff --git a/src/main/runtime/client-session-tab-selection-persistence.ts b/src/main/runtime/client-session-tab-selection-persistence.ts index 358144cb49d..81810b7371f 100644 --- a/src/main/runtime/client-session-tab-selection-persistence.ts +++ b/src/main/runtime/client-session-tab-selection-persistence.ts @@ -2,6 +2,105 @@ import type { PersistedMobileClientTabSelection, PersistedMobileClientTabSelections } from '../../shared/types' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export const MOBILE_TAB_SELECTION_MAX_CLIENTS = 64 +export const MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT = 512 +export const MOBILE_TAB_SELECTION_MAX_GROUPS_PER_WORKTREE = 128 +export const MOBILE_TAB_SELECTION_ID_MAX_BYTES = 4 * 1024 +export const MOBILE_TAB_SELECTION_MAX_BYTES_PER_SELECTION = 64 * 1024 +export const MOBILE_TAB_SELECTION_MAX_BYTES_PER_CLIENT = 256 * 1024 + +export function isMobileTabSelectionIdRetainable(value: string): boolean { + return !measureUtf8ByteLength(value, { + stopAfterBytes: MOBILE_TAB_SELECTION_ID_MAX_BYTES + }).exceededLimit +} + +function mobileTabSelectionStringBytes(value: string | null): number { + return value ? measureUtf8ByteLength(value).byteLength : 0 +} + +export function mobileTabSelectionRetainedBytes( + worktreeId: string, + selection: PersistedMobileClientTabSelection +): number { + let bytes = + mobileTabSelectionStringBytes(worktreeId) + + mobileTabSelectionStringBytes(selection.activeTabId) + + mobileTabSelectionStringBytes(selection.activeGroupId) + for (const [groupId, tabId] of Object.entries(selection.activeTabIdByGroupId)) { + bytes += mobileTabSelectionStringBytes(groupId) + mobileTabSelectionStringBytes(tabId) + } + return bytes +} + +function newestOwnEntries( + record: Record, + maxEntries: number, + acceptKey: (key: string) => boolean = () => true +): [string, unknown][] { + const entries: [string, unknown][] = [] + let nextReplacementIndex = 0 + for (const key in record) { + if (!Object.prototype.hasOwnProperty.call(record, key)) { + continue + } + if (!acceptKey(key)) { + continue + } + const entry: [string, unknown] = [key, record[key]] + if (entries.length < maxEntries) { + entries.push(entry) + } else { + entries[nextReplacementIndex] = entry + nextReplacementIndex = (nextReplacementIndex + 1) % maxEntries + } + } + return nextReplacementIndex === 0 + ? entries + : [...entries.slice(nextReplacementIndex), ...entries.slice(0, nextReplacementIndex)] +} + +export function boundMobileClientTabSelectionGroups( + selection: PersistedMobileClientTabSelection +): PersistedMobileClientTabSelection { + const activeTabId = + selection.activeTabId && isMobileTabSelectionIdRetainable(selection.activeTabId) + ? selection.activeTabId + : null + const activeGroupId = + selection.activeGroupId && isMobileTabSelectionIdRetainable(selection.activeGroupId) + ? selection.activeGroupId + : null + let retainedBytes = + mobileTabSelectionStringBytes(activeTabId) + mobileTabSelectionStringBytes(activeGroupId) + const retainedGroups: [string, string][] = [] + const candidates = newestOwnEntries( + selection.activeTabIdByGroupId, + MOBILE_TAB_SELECTION_MAX_GROUPS_PER_WORKTREE, + isMobileTabSelectionIdRetainable + ).filter( + (entry): entry is [string, string] => + typeof entry[1] === 'string' && isMobileTabSelectionIdRetainable(entry[1]) + ) + for (let index = candidates.length - 1; index >= 0; index--) { + const entry = candidates[index]! + const entryBytes = + mobileTabSelectionStringBytes(entry[0]) + mobileTabSelectionStringBytes(entry[1]) + if (retainedBytes + entryBytes > MOBILE_TAB_SELECTION_MAX_BYTES_PER_SELECTION) { + break + } + retainedBytes += entryBytes + retainedGroups.push(entry) + } + return { + ...selection, + activeTabId, + activeGroupId, + activeTabIdByGroupId: Object.fromEntries(retainedGroups.toReversed()) + } +} function normalizeClientSessionTabSelection( raw: unknown @@ -10,16 +109,28 @@ function normalizeClientSessionTabSelection( return null } const candidate = raw as Partial - const activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null - const activeGroupId = typeof candidate.activeGroupId === 'string' ? candidate.activeGroupId : null + const activeTabId = + typeof candidate.activeTabId === 'string' && + isMobileTabSelectionIdRetainable(candidate.activeTabId) + ? candidate.activeTabId + : null + const activeGroupId = + typeof candidate.activeGroupId === 'string' && + isMobileTabSelectionIdRetainable(candidate.activeGroupId) + ? candidate.activeGroupId + : null const activeTabIdByGroupId: Record = {} if ( typeof candidate.activeTabIdByGroupId === 'object' && candidate.activeTabIdByGroupId && !Array.isArray(candidate.activeTabIdByGroupId) ) { - for (const [groupId, tabId] of Object.entries(candidate.activeTabIdByGroupId)) { - if (typeof tabId === 'string') { + for (const [groupId, tabId] of newestOwnEntries( + candidate.activeTabIdByGroupId as Record, + MOBILE_TAB_SELECTION_MAX_GROUPS_PER_WORKTREE, + isMobileTabSelectionIdRetainable + )) { + if (typeof tabId === 'string' && isMobileTabSelectionIdRetainable(tabId)) { activeTabIdByGroupId[groupId] = tabId } } @@ -27,7 +138,7 @@ function normalizeClientSessionTabSelection( if (!activeTabId && !activeGroupId && Object.keys(activeTabIdByGroupId).length === 0) { return null } - return { activeTabId, activeGroupId, activeTabIdByGroupId } + return boundMobileClientTabSelectionGroups({ activeTabId, activeGroupId, activeTabIdByGroupId }) } // Why: this state comes off disk (and, for remote runtimes, another machine); a bad payload must degrade to "no selection", not throw. @@ -38,7 +149,11 @@ export function normalizePersistedMobileClientTabSelections( if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { return normalized } - for (const [clientNavigationId, selectionsByWorktree] of Object.entries(raw)) { + for (const [clientNavigationId, selectionsByWorktree] of newestOwnEntries( + raw as Record, + MOBILE_TAB_SELECTION_MAX_CLIENTS, + isMobileTabSelectionIdRetainable + )) { if ( typeof selectionsByWorktree !== 'object' || selectionsByWorktree === null || @@ -46,15 +161,29 @@ export function normalizePersistedMobileClientTabSelections( ) { continue } - const entries: Record = {} - for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) { + const entries = new Map() + let retainedBytes = 0 + for (const [worktreeId, selection] of newestOwnEntries( + selectionsByWorktree as Record, + MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT, + isMobileTabSelectionIdRetainable + )) { const normalizedSelection = normalizeClientSessionTabSelection(selection) if (normalizedSelection) { - entries[worktreeId] = normalizedSelection + entries.set(worktreeId, normalizedSelection) + retainedBytes += mobileTabSelectionRetainedBytes(worktreeId, normalizedSelection) + while (retainedBytes > MOBILE_TAB_SELECTION_MAX_BYTES_PER_CLIENT) { + const oldest = entries.entries().next() + if (oldest.done) { + break + } + entries.delete(oldest.value[0]) + retainedBytes -= mobileTabSelectionRetainedBytes(oldest.value[0], oldest.value[1]) + } } } - if (Object.keys(entries).length > 0) { - normalized[clientNavigationId] = entries + if (entries.size > 0) { + normalized[clientNavigationId] = Object.fromEntries(entries) } } return normalized diff --git a/src/main/runtime/client-session-tab-selection-retention.ts b/src/main/runtime/client-session-tab-selection-retention.ts new file mode 100644 index 00000000000..0dd7672a49d --- /dev/null +++ b/src/main/runtime/client-session-tab-selection-retention.ts @@ -0,0 +1,64 @@ +import type { ClientSessionTabSelection } from './client-session-tab-selection' +import { + isMobileTabSelectionIdRetainable, + MOBILE_TAB_SELECTION_MAX_BYTES_PER_CLIENT, + MOBILE_TAB_SELECTION_MAX_CLIENTS, + MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT, + mobileTabSelectionRetainedBytes +} from './client-session-tab-selection-persistence' + +export type StoredClientSessionTabSelection = { + selection: ClientSessionTabSelection + revision: number + shouldPersist: boolean +} + +export function getOrCreateClientTabSelectionWorktrees( + statesByClient: Map>, + clientNavigationId: string +): Map { + let statesByWorktree = statesByClient.get(clientNavigationId) + if (!statesByWorktree) { + if (statesByClient.size >= MOBILE_TAB_SELECTION_MAX_CLIENTS) { + const oldestClientId = statesByClient.keys().next().value + if (oldestClientId !== undefined) { + statesByClient.delete(oldestClientId) + } + } + statesByWorktree = new Map() + statesByClient.set(clientNavigationId, statesByWorktree) + } + return statesByWorktree +} + +export function rememberClientTabSelectionWorktree( + statesByWorktree: Map, + worktreeId: string, + state: StoredClientSessionTabSelection +): void { + if (!isMobileTabSelectionIdRetainable(worktreeId)) { + return + } + if ( + !statesByWorktree.has(worktreeId) && + statesByWorktree.size >= MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT + ) { + const oldestWorktreeId = statesByWorktree.keys().next().value + if (oldestWorktreeId !== undefined) { + statesByWorktree.delete(oldestWorktreeId) + } + } + statesByWorktree.set(worktreeId, state) + let retainedBytes = 0 + for (const [retainedWorktreeId, retainedState] of statesByWorktree) { + retainedBytes += mobileTabSelectionRetainedBytes(retainedWorktreeId, retainedState.selection) + } + while (retainedBytes > MOBILE_TAB_SELECTION_MAX_BYTES_PER_CLIENT) { + const oldest = statesByWorktree.entries().next() + if (oldest.done) { + break + } + statesByWorktree.delete(oldest.value[0]) + retainedBytes -= mobileTabSelectionRetainedBytes(oldest.value[0], oldest.value[1].selection) + } +} diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts index 7c8f84f88d2..5af3bd48cf7 100644 --- a/src/main/runtime/client-session-tab-selection.ts +++ b/src/main/runtime/client-session-tab-selection.ts @@ -3,7 +3,16 @@ import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import type { PersistedMobileClientTabSelections } from '../../shared/types' -import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence' +import { + boundMobileClientTabSelectionGroups, + isMobileTabSelectionIdRetainable, + normalizePersistedMobileClientTabSelections +} from './client-session-tab-selection-persistence' +import { + getOrCreateClientTabSelectionWorktrees, + rememberClientTabSelectionWorktree, + type StoredClientSessionTabSelection +} from './client-session-tab-selection-retention' export type ClientSessionTabSelection = { activeTabId: string | null @@ -11,13 +20,6 @@ export type ClientSessionTabSelection = { activeTabIdByGroupId: Readonly> } -type StoredClientSessionTabSelection = { - selection: ClientSessionTabSelection - revision: number - // Why: listAll projects every worktree; only hydrated or user-activated selections belong on disk. - shouldPersist: boolean -} - function emptyClientSessionTabSelection(): ClientSessionTabSelection { return { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} } } @@ -42,7 +44,7 @@ function findTabByTopLevelId( export function deriveClientSessionTabSelection( snapshot: RuntimeMobileSessionTabsResult ): ClientSessionTabSelection { - return { + return boundMobileClientTabSelectionGroups({ activeTabId: snapshot.activeTabId, activeGroupId: snapshot.activeGroupId, activeTabIdByGroupId: Object.fromEntries( @@ -50,7 +52,7 @@ export function deriveClientSessionTabSelection( group.activeTabId ? [[group.id, group.activeTabId] as const] : [] ) ?? [] ) - } + }) } export function activateClientSessionTabSelection( @@ -66,13 +68,13 @@ export function activateClientSessionTabSelection( const activeGroup = snapshot.tabGroups?.find((group) => group.tabOrder.includes(activeTopLevelTabId) ) - return { + return boundMobileClientTabSelectionGroups({ activeTabId, activeGroupId: activeGroup?.id ?? selection.activeGroupId, activeTabIdByGroupId: activeGroup ? { ...selection.activeTabIdByGroupId, [activeGroup.id]: activeTopLevelTabId } : selection.activeTabIdByGroupId - } + }) } export function projectClientSessionTabSelection( @@ -110,11 +112,11 @@ export function projectClientSessionTabSelection( : null) ?? tabGroups?.[0]?.id ?? null - const nextSelection: ClientSessionTabSelection = { + const nextSelection: ClientSessionTabSelection = boundMobileClientTabSelectionGroups({ activeTabId: activeTab?.id ?? null, activeGroupId, activeTabIdByGroupId - } + }) return { selection: nextSelection, snapshot: { @@ -137,9 +139,16 @@ export class ClientSessionTabSelectionStore { for (const [clientNavigationId, selectionsByWorktree] of Object.entries( normalizePersistedMobileClientTabSelections(persisted) )) { - const statesByWorktree = this.getStatesByWorktree(clientNavigationId) + const statesByWorktree = getOrCreateClientTabSelectionWorktrees( + this.statesByClient, + clientNavigationId + ) for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) { - statesByWorktree.set(worktreeId, { selection, revision: 0, shouldPersist: true }) + rememberClientTabSelectionWorktree(statesByWorktree, worktreeId, { + selection, + revision: 0, + shouldPersist: true + }) } } } @@ -168,25 +177,21 @@ export class ClientSessionTabSelectionStore { this.persistListener?.(this.serialize()) } - private getStatesByWorktree( - clientNavigationId: string - ): Map { - let statesByWorktree = this.statesByClient.get(clientNavigationId) - if (!statesByWorktree) { - statesByWorktree = new Map() - this.statesByClient.set(clientNavigationId, statesByWorktree) - } - return statesByWorktree - } - project( snapshot: RuntimeMobileSessionTabsResult, clientNavigationId?: string ): RuntimeMobileSessionTabsResult { - if (!clientNavigationId) { + if ( + !clientNavigationId || + !isMobileTabSelectionIdRetainable(clientNavigationId) || + !isMobileTabSelectionIdRetainable(snapshot.worktree) + ) { return snapshot } - const statesByWorktree = this.getStatesByWorktree(clientNavigationId) + const statesByWorktree = getOrCreateClientTabSelectionWorktrees( + this.statesByClient, + clientNavigationId + ) const state = statesByWorktree.get(snapshot.worktree) ?? { // Why: host focus is private navigation; a new paired device starts from deterministic topology instead of inheriting it. selection: emptyClientSessionTabSelection(), @@ -202,7 +207,7 @@ export class ClientSessionTabSelectionStore { } } const projected = projectClientSessionTabSelection(snapshot, state.selection) - statesByWorktree.set(snapshot.worktree, { + rememberClientTabSelectionWorktree(statesByWorktree, snapshot.worktree, { selection: projected.selection, revision: state.revision, shouldPersist: state.shouldPersist @@ -219,14 +224,23 @@ export class ClientSessionTabSelectionStore { clientNavigationId: string, activeTabId: string ): RuntimeMobileSessionTabsResult { - const statesByWorktree = this.getStatesByWorktree(clientNavigationId) + if ( + !isMobileTabSelectionIdRetainable(clientNavigationId) || + !isMobileTabSelectionIdRetainable(snapshot.worktree) + ) { + return snapshot + } + const statesByWorktree = getOrCreateClientTabSelectionWorktrees( + this.statesByClient, + clientNavigationId + ) const state = statesByWorktree.get(snapshot.worktree) ?? { selection: emptyClientSessionTabSelection(), revision: 0, shouldPersist: false } const nextSelection = activateClientSessionTabSelection(snapshot, state.selection, activeTabId) - statesByWorktree.set(snapshot.worktree, { + rememberClientTabSelectionWorktree(statesByWorktree, snapshot.worktree, { selection: nextSelection, revision: state.revision + 1, shouldPersist: true @@ -255,8 +269,8 @@ export class ClientSessionTabSelectionStore { if (!state) { continue } - statesByWorktree.set(newWorktreeId, state) statesByWorktree.delete(oldWorktreeId) + rememberClientTabSelectionWorktree(statesByWorktree, newWorktreeId, state) changed = state.shouldPersist || changed } if (changed) { diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index d246b1f7309..7750728b965 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -3,9 +3,12 @@ // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. import { randomBytes, randomUUID } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { join } from 'node:path' -import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' +import { JsonStringifyByteLimitError } from '../../shared/memory-safety/node-bounded-json-stringify' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { writeSecureJsonFileWithinLimit } from '../../shared/memory-safety/bounded-secure-json-file' +import { hardenExistingSecureFile } from '../../shared/secure-file' import type { DeviceScope } from '../../shared/runtime-types' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' @@ -13,6 +16,16 @@ import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-co export type { DeviceScope } +export const MAX_DEVICE_REGISTRY_FILE_BYTES = 1024 * 1024 +export const MAX_DEVICE_REGISTRY_ENTRIES = 4096 + +export class DeviceRegistryCapacityError extends Error { + constructor() { + super('Device registry exceeds its durable capacity') + this.name = 'DeviceRegistryCapacityError' + } +} + export type DeviceEntry = { deviceId: string name: string @@ -61,6 +74,9 @@ export class DeviceRegistry { name: string, scope: DeviceScope ): DeviceEntry { + if (existingDevices.length >= MAX_DEVICE_REGISTRY_ENTRIES) { + throw new DeviceRegistryCapacityError() + } const entry: DeviceEntry = { deviceId: randomUUID(), name, @@ -102,13 +118,13 @@ export class DeviceRegistry { } removeDevice(deviceId: string): boolean { - const before = this.devices.length - this.devices = this.devices.filter((d) => d.deviceId !== deviceId) - if (this.devices.length < before) { - this.save() - return true + const nextDevices = this.devices.filter((device) => device.deviceId !== deviceId) + if (nextDevices.length === this.devices.length) { + return false } - return false + this.save(nextDevices) + this.devices = nextDevices + return true } getDevice(deviceId: string): DeviceEntry | null { @@ -124,8 +140,12 @@ export class DeviceRegistry { if (!device || binding.relayDeviceId !== deviceId) { return false } + this.save( + this.devices.map((candidate) => + candidate === device ? { ...candidate, relayBinding: binding } : candidate + ) + ) device.relayBinding = binding - this.save() return true } @@ -134,8 +154,12 @@ export class DeviceRegistry { if (!device || device.scope !== 'mobile') { return false } + this.save( + this.devices.map((candidate) => + candidate === device ? { ...candidate, mobilePairingConnectionMode: mode } : candidate + ) + ) device.mobilePairingConnectionMode = mode - this.save() return true } @@ -160,8 +184,13 @@ export class DeviceRegistry { updateLastSeen(deviceId: string): void { const device = this.devices.find((d) => d.deviceId === deviceId) if (device) { - device.lastSeenAt = Date.now() - this.save() + const lastSeenAt = Date.now() + this.save( + this.devices.map((candidate) => + candidate === device ? { ...candidate, lastSeenAt } : candidate + ) + ) + device.lastSeenAt = lastSeenAt } } @@ -172,8 +201,16 @@ export class DeviceRegistry { } try { hardenExistingSecureFile(this.registryPath) - const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] - this.devices = parsed.map((device) => ({ + const parsed: unknown = JSON.parse( + readNodeFileSyncWithinLimit( + this.registryPath, + MAX_DEVICE_REGISTRY_FILE_BYTES + ).buffer.toString('utf8') + ) + if (!Array.isArray(parsed) || parsed.length > MAX_DEVICE_REGISTRY_ENTRIES) { + throw new DeviceRegistryCapacityError() + } + this.devices = (parsed as DeviceEntry[]).map((device) => ({ ...device, // Why: older registries only existed for phone pairing. Treat missing // scope as mobile so legacy device tokens do not gain new CLI powers. @@ -188,6 +225,13 @@ export class DeviceRegistry { } private save(devices: DeviceEntry[] = this.devices): void { - writeSecureJsonFile(this.registryPath, devices) + try { + writeSecureJsonFileWithinLimit(this.registryPath, devices, MAX_DEVICE_REGISTRY_FILE_BYTES) + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + throw new DeviceRegistryCapacityError() + } + throw error + } } } diff --git a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts b/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts deleted file mode 100644 index f25f6c9051a..00000000000 --- a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../shared/types' - -const { - resolveAuthorizedPathMock, - statMock, - watchInWatcherProcessMock, - closeWatcherInWatcherProcessMock, - getSshFilesystemProviderMock, - providerRegistrationListeners -} = vi.hoisted(() => ({ - resolveAuthorizedPathMock: vi.fn(), - statMock: vi.fn(), - watchInWatcherProcessMock: vi.fn(), - closeWatcherInWatcherProcessMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn(), - providerRegistrationListeners: new Set<(connectionId: string) => void>() -})) - -vi.mock('fs/promises', async () => { - const actual = await vi.importActual>('fs/promises') - return { ...actual, stat: statMock } -}) -vi.mock('./file-watcher-host', () => ({ - closeFileExplorerWatcherInWatcherProcess: closeWatcherInWatcherProcessMock, - watchFileExplorerInWatcherProcess: watchInWatcherProcessMock -})) -vi.mock('../ipc/filesystem-auth', async () => { - const actual = await vi.importActual>('../ipc/filesystem-auth') - return { ...actual, resolveAuthorizedPath: resolveAuthorizedPathMock } -}) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.', - onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { - providerRegistrationListeners.add(listener) - return () => providerRegistrationListeners.delete(listener) - } -})) - -import { - _resetRuntimeFileWatcherLeasesForTests, - awaitRuntimeFileWatcherUnsubscribes, - RuntimeFileCommands -} from './orca-runtime-files' - -const ROOT_PATH = '/home/me/repo' -const CONNECTION_ID = 'conn-1' -const OVERFLOW_EVENTS: FsChangeEvent[] = [{ kind: 'overflow', absolutePath: ROOT_PATH }] - -/** Drive the provider-registration hook the way a relay reconnect would. */ -function emitProviderRegistered(connectionId: string): void { - for (const listener of providerRegistrationListeners) { - listener(connectionId) - } -} - -function createRuntimeFileCommands(): RuntimeFileCommands { - return new RuntimeFileCommands({ - getRuntimeId: () => 'runtime-1', - requireStore: () => ({ getRepo: vi.fn(() => undefined) }), - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH })), - resolveRuntimeFileTarget: vi.fn(async () => ({ - worktree: { id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH }, - connectionId: CONNECTION_ID - })), - resolveRuntimeGitTarget: vi.fn(), - openFile: vi.fn() - } as never) -} - -describe('remote file-explorer watch re-arm', () => { - beforeEach(() => { - resolveAuthorizedPathMock.mockReset() - statMock.mockReset() - watchInWatcherProcessMock.mockReset() - closeWatcherInWatcherProcessMock.mockReset() - getSshFilesystemProviderMock.mockReset() - providerRegistrationListeners.clear() - }) - - afterEach(async () => { - await awaitRuntimeFileWatcherUnsubscribes() - _resetRuntimeFileWatcherLeasesForTests() - }) - - it('reinstalls and resyncs when the connection re-registers its provider', async () => { - // Why: dispose() on transport loss stops the registration without firing onTerminalError, so - // nothing else tells this watch it died. - const firstUnwatch = vi.fn() - const secondUnwatch = vi.fn() - const watch = vi.fn().mockResolvedValueOnce(firstUnwatch).mockResolvedValueOnce(secondUnwatch) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const commands = createRuntimeFileCommands() - const onEvents = vi.fn() - - await commands.watchFileExplorer('id:wt-1', onEvents) - expect(watch).toHaveBeenCalledTimes(1) - - emitProviderRegistered(CONNECTION_ID) - await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(2)) - - expect(onEvents).toHaveBeenCalledWith(OVERFLOW_EVENTS) - // The dead transport's handle must not be closed against the fresh registration. - expect(firstUnwatch).not.toHaveBeenCalled() - }) - - it('ignores registrations for other connections', async () => { - const watch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const commands = createRuntimeFileCommands() - - await commands.watchFileExplorer('id:wt-1', vi.fn()) - emitProviderRegistered('conn-other') - await Promise.resolve() - - expect(watch).toHaveBeenCalledTimes(1) - }) - - it('stops re-arming after the watch is released', async () => { - const watch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const commands = createRuntimeFileCommands() - - const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) - await unsubscribe() - emitProviderRegistered(CONNECTION_ID) - await Promise.resolve() - - expect(watch).toHaveBeenCalledTimes(1) - }) - - it('stops re-arming after the worktree is removed', async () => { - const watch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const commands = createRuntimeFileCommands() - - await commands.watchFileExplorer('id:wt-1', vi.fn()) - commands.forgetFileExplorerWatchersAfterRemoval(ROOT_PATH, CONNECTION_ID) - emitProviderRegistered(CONNECTION_ID) - await Promise.resolve() - - expect(watch).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index 379372dd0ac..3bb68dcff51 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -14,6 +14,7 @@ import type * as GitRunner from '../git/runner' const { lstatMock, openMock, + opendirMock, readdirMock, renameMock, resolveAuthorizedPathMock, @@ -29,6 +30,7 @@ const { getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), lstatMock: vi.fn(), openMock: vi.fn(), + opendirMock: vi.fn(), readdirMock: vi.fn(), renameMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), @@ -56,6 +58,10 @@ vi.mock('fs/promises', async () => { const impl = openMock.getMockImplementation() return impl ? openMock(...args) : actual.open(...args) }, + opendir: (...args: Parameters) => { + const impl = opendirMock.getMockImplementation() + return impl ? opendirMock(...args) : actual.opendir(...args) + }, readdir: readdirMock, rename: renameMock, stat: statMock @@ -93,12 +99,15 @@ vi.mock('../ipc/local-worktree-runtime-options', () => ({ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn(), - onSshFilesystemProviderRegistered: () => () => undefined, SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' })) -import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' +import { + awaitRuntimeFileWatcherUnsubscribes, + classifyRuntimeMobileDirectoryEntries, + RuntimeFileCommands +} from './orca-runtime-files' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resetSshConnectionGenerations, @@ -106,6 +115,16 @@ import { } from '../ssh/ssh-connection-generation' import { SEARCH_TIMEOUT_MS } from '../../shared/text-search' +function pngHeader(width = 1, height = 1): Buffer { + const bytes = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) + bytes.writeUInt32BE(13, 8) + bytes.write('IHDR', 12, 'ascii') + bytes.writeUInt32BE(width, 16) + bytes.writeUInt32BE(height, 20) + return bytes +} + type MockRuntimeSearchChild = EventEmitter & { stdout: EventEmitter & { setEncoding: ReturnType } stderr: EventEmitter @@ -203,6 +222,7 @@ describe('RuntimeFileCommands', () => { vi.useFakeTimers() lstatMock.mockReset() openMock.mockReset() + opendirMock.mockReset() readdirMock.mockReset() renameMock.mockReset() resolveAuthorizedPathMock.mockReset() @@ -213,6 +233,7 @@ describe('RuntimeFileCommands', () => { checkRgAvailableMock.mockReset() vi.mocked(getSshFilesystemProvider).mockReset() resetSshConnectionGenerations() + setSshConnectionGeneration('ssh-1', 0) getLocalGitOptionsForRegisteredWorktreeMock.mockReset() wslAwareSpawnMock.mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) @@ -299,6 +320,46 @@ describe('RuntimeFileCommands', () => { }) }) + it('validates local file explorer raster dimensions before returning base64', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-runtime-image-preview-')) + const filePath = join(dir, 'image.png') + const content = pngHeader(640, 480) + await writeFile(filePath, content) + const { commands } = createRuntimeFileCommands({ path: dir }) + resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) + statMock.mockResolvedValue({ size: content.length }) + + try { + await expect(commands.readFileExplorerPreview('id:wt-1', 'image.png')).resolves.toEqual({ + content: content.toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png', + imageDimensions: { width: 640, height: 480 } + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('rejects a local file explorer raster dimension bomb', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-runtime-image-bomb-')) + const filePath = join(dir, 'bomb.png') + const content = pngHeader(32_769, 1) + await writeFile(filePath, content) + const { commands } = createRuntimeFileCommands({ path: dir }) + resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) + statMock.mockResolvedValue({ size: content.length }) + + try { + await expect(commands.readFileExplorerPreview('id:wt-1', 'bomb.png')).rejects.toThrow( + 'Image dimensions exceed the preview safety limit' + ) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + it('leaves non-previewable binaries unavailable on mobile', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) @@ -355,10 +416,12 @@ describe('RuntimeFileCommands', () => { it('does not follow symlinks when reading runtime-local file explorer dirs', async () => { const { commands } = createRuntimeFileCommands() resolveAuthorizedPathMock.mockResolvedValue('/repo') - readdirMock.mockResolvedValue([ - dirEntry({ name: 'README.md' }), - dirEntry({ name: 'linked-docs', directory: true, symlink: true }) - ]) + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield dirEntry({ name: 'README.md' }) + yield dirEntry({ name: 'linked-docs', directory: true, symlink: true }) + } + }) const result = await commands.readFileExplorerDir('id:wt-1', '') @@ -369,6 +432,46 @@ describe('RuntimeFileCommands', () => { expect(statMock).not.toHaveBeenCalledWith('/repo/linked-docs') }) + it('stops runtime-local directory enumeration at the mobile entry limit', async () => { + const { commands } = createRuntimeFileCommands() + resolveAuthorizedPathMock.mockResolvedValue('/repo') + let enumerated = 0 + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + while (enumerated < 20_000) { + enumerated += 1 + yield dirEntry({ name: 'entry' }) + } + } + }) + + await expect(commands.readFileExplorerDir('id:wt-1', '')).rejects.toThrow( + 'This folder is too large to show safely on mobile' + ) + expect(enumerated).toBe(10_001) + }) + + it('bounds runtime-local directory classification while preserving entry order', async () => { + let active = 0 + let maxActive = 0 + const classify = vi.fn(async () => { + active += 1 + maxActive = Math.max(maxActive, active) + await Promise.resolve() + active -= 1 + return true + }) + const entries = Array.from({ length: 100 }, (_, index) => + dirEntry({ name: `entry-${index}`, symlink: true }) + ) + + const result = await classifyRuntimeMobileDirectoryEntries('/repo', entries, classify) + + expect(maxActive).toBe(32) + expect(result.map((entry) => entry.name)).toEqual(entries.map((entry) => entry.name)) + expect(result.every((entry) => entry.isDirectory && entry.isSymlink)).toBe(true) + }) + it('renames a runtime-local file when destination does not exist', async () => { const { commands } = createRuntimeFileCommands() resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) @@ -800,7 +903,7 @@ describe('RuntimeFileCommands', () => { tempDirs = [] }) - async function tempFile(name: string, content: string): Promise { + async function tempFile(name: string, content: string | Uint8Array): Promise { const dir = await mkdtemp(join(tmpdir(), 'orca-terminal-artifact-')) tempDirs.push(dir) const filePath = join(dir, name) @@ -1767,6 +1870,49 @@ describe('RuntimeFileCommands', () => { ).rejects.toThrow('terminal_file_grant_stale') }) + it('validates local terminal artifact raster dimensions before returning base64', async () => { + const content = pngHeader(320, 240) + const artifactPath = await tempFile('result.png', content) + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + + await expect( + commands.readTerminalArtifactPreview( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).resolves.toEqual({ + content: content.toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png', + imageDimensions: { width: 320, height: 240 } + }) + }) + + it('rejects a local terminal artifact raster dimension bomb', async () => { + const artifactPath = await tempFile('result.png', pngHeader(32_769, 1)) + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + + await expect( + commands.readTerminalArtifactPreview( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('Image dimensions exceed the preview safety limit') + }) + it('rejects binary-extension terminal artifacts from the editable text path', async () => { const artifactPath = await tempFile('report.pdf', '%PDF text-looking bytes') const { commands } = createRuntimeFileCommands({ path: '/repo' }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index e37c7106747..8c5362dcd83 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: filesystem, editor-file, and search commands share the same local/SSH path authorization rules. Keeping that IO adapter together prevents separate command paths from drifting on safety checks. */ import type { ChildProcess } from 'node:child_process' import { randomUUID } from 'node:crypto' -import { watch as watchFs } from 'node:fs' +import { watch as watchFs, type Dirent } from 'node:fs' import type { FileHandle } from 'node:fs/promises' import { chmod, @@ -10,8 +10,7 @@ import { lstat, mkdir, open, - readFile, - readdir, + opendir, rename, realpath, rm, @@ -38,6 +37,13 @@ import { resolveRuntimePath } from '../../shared/cross-platform-path' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' +import { + assertMobileFileDirectoryWithinLimit, + createMobileFileDirectoryLimitState, + MOBILE_FILE_DIRECTORY_MAX_ENTRIES, + MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES, + trackMobileFileDirectoryEntry +} from '../../shared/mobile-file-directory-limit' import type { RuntimeFileListResult, RuntimeFileOpenResult, @@ -72,7 +78,6 @@ import { import type { Store } from '../persistence' import { getSshFilesystemProvider, - onSshFilesystemProviderRegistered, SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' import type { FileStat, IFilesystemProvider } from '../providers/types' @@ -81,6 +86,10 @@ import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/memory-safety/node-bounded-file-reader' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' import { rankRuntimeMobileFilePaths, @@ -89,12 +98,18 @@ import { import { beginWatcherInstall } from '../ipc/watcher-removal-gate' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { toSshExecutionHostId } from '../../shared/execution-host' +import { assertRasterImagePreviewWithinLimits } from '../../shared/raster-image-preview-limits' +import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' +import { retainRuntimeTerminalFileGrant } from './runtime-terminal-file-grant-retention' +import { assertRuntimeTextSearchAdmission } from './runtime-text-search-admission' +import { RuntimeFileWatcherAdmission } from './runtime-file-watcher-admission' const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 +const RUNTIME_MOBILE_DIRECTORY_CLASSIFICATION_CONCURRENCY = 32 const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000 @@ -127,10 +142,7 @@ type RuntimeFileWatcherLease = { forget(): void } const runtimeFileWatcherLeasesByOwnerAndRoot = new Map>() -// Why: the provider's dispose() stops each watch registration without firing its terminal callback, -// so a dropped SSH transport leaves this watch silently dead — a reconnect's fresh provider is the -// only signal it can be rebuilt from. Keyed like the leases so worktree removal can drop it. -const sshFileExplorerWatchRearms = new Map void>>() +const runtimeFileWatcherAdmission = new RuntimeFileWatcherAdmission() const MOBILE_BINARY_EXTENSIONS = new Set([ '.avif', '.bmp', @@ -230,101 +242,14 @@ function runtimeWatcherReleaseKey( return JSON.stringify([runtimeId, connectionId ?? null, normalizeRuntimeWatcherRoot(rootPath)]) } -/** - * Keep an SSH file-explorer watch alive across reconnects. - * - * Why: the previous provider's unwatch handle belongs to the dead transport, so reinstalling on the - * fresh provider is the only way the subscription comes back. Callers get an overflow because the - * events lost while the watch was down can't be replayed. - */ -function armSshFileExplorerWatchRearm(args: { - runtimeId: string - connectionId: string - rootPath: string - callback: (events: FsChangeEvent[]) => void - onTerminalError: (error: Error) => void - signal?: AbortSignal - initialUnwatch: () => void -}): { unsubscribe: () => Promise } { - const key = runtimeWatcherReleaseKey(args.runtimeId, args.connectionId, args.rootPath) - let currentUnwatch = args.initialUnwatch - let stopped = false - let reinstalling: Promise | null = null - - const reinstall = async (): Promise => { - const provider = getSshFilesystemProvider(args.connectionId) - if (stopped || !provider) { - return - } - // Why: the old handle is scoped to the dead transport; closing it here would only risk - // unwatching the root we just re-registered on the new one. - const nextUnwatch = await provider.watch(args.rootPath, args.callback, { - signal: args.signal, - onTerminalError: args.onTerminalError - }) - if (stopped) { - nextUnwatch() - return - } - currentUnwatch = nextUnwatch - args.callback([{ kind: 'overflow', absolutePath: args.rootPath }]) - } - - const unsubscribeRearm = onSshFilesystemProviderRegistered((registeredId) => { - if (registeredId !== args.connectionId || stopped) { - return - } - // Why: reconnect storms can register repeatedly; chain so a second one can't double-install. - const attempt = (reinstalling ?? Promise.resolve()) - .then(reinstall) - .catch((error: unknown) => { - args.onTerminalError(error instanceof Error ? error : new Error(String(error))) - }) - .finally(() => { - if (reinstalling === attempt) { - reinstalling = null - } - }) - reinstalling = attempt - }) - - const stop = (): void => { - stopped = true - unsubscribeRearm() - const rearms = sshFileExplorerWatchRearms.get(key) - rearms?.delete(stop) - if (rearms?.size === 0) { - sshFileExplorerWatchRearms.delete(key) - } - } - const rearms = sshFileExplorerWatchRearms.get(key) ?? new Set<() => void>() - rearms.add(stop) - sshFileExplorerWatchRearms.set(key, rearms) - - return { - unsubscribe: () => { - stop() - const close = async (): Promise => currentUnwatch() - // Why: awaiting an absent reinstall costs a microtask, and removal gating relies on the - // unwatch being issued on the same turn the lease releases it. - return reinstalling ? reinstalling.catch(() => undefined).then(close) : close() - } - } -} - -function stopSshFileExplorerWatchRearms(key: string): void { - for (const stop of Array.from(sshFileExplorerWatchRearms.get(key) ?? [])) { - stop() - } -} - function registerRuntimeFileWatcherRelease( runtimeId: string, connectionId: string | undefined, rootPaths: string[], unsubscribe: () => Promise, restart: () => Promise<() => Promise>, - onRestoreError: (error: Error) => void + onRestoreError: (error: Error) => void, + releaseAdmission: () => void ): () => Promise { const keys = Array.from( new Set( @@ -345,6 +270,7 @@ function registerRuntimeFileWatcherRelease( runtimeFileWatcherLeasesByOwnerAndRoot.delete(key) } } + releaseAdmission() } const suspend = (): Promise => { if (releasePromise) { @@ -478,9 +404,6 @@ export function _resetRuntimeFileWatcherLeasesForTests(): void { for (const lease of leases) { lease.forget() } - for (const key of Array.from(sshFileExplorerWatchRearms.keys())) { - stopSshFileExplorerWatchRearms(key) - } runtimeFileWatcherLeasesByOwnerAndRoot.clear() } @@ -926,7 +849,10 @@ export class RuntimeFileCommands { expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS, statIdentity: terminalFileStatIdentity(args.stats) } - this.terminalFileGrants.set(grant.id, grant) + this.pruneExpiredTerminalFileGrants() + retainRuntimeTerminalFileGrant(this.terminalFileGrants, grant, (id, retained) => + this.releaseTerminalFileGrant(id, retained) + ) this.scheduleTerminalFileGrantExpiry(grant) return grant } @@ -1258,21 +1184,23 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.readDir(target.path) + const entries = await provider.readDir(target.path, { + maxEntries: MOBILE_FILE_DIRECTORY_MAX_ENTRIES, + maxRetainedBytes: MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES + }) + assertMobileFileDirectoryWithinLimit(entries) + return entries } const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = await Promise.all( - entries.map(async (entry) => { - const entryPath = join(dirPath, entry.name) - return { - name: entry.name, - isDirectory: await isRuntimeDirectoryEntry(entry, entryPath), - isSymlink: entry.isSymbolicLink() - } - }) - ) + const limit = createMobileFileDirectoryLimitState() + const directory = await opendir(dirPath) + const entries: Dirent[] = [] + for await (const entry of directory) { + trackMobileFileDirectoryEntry(limit, entry) + entries.push(entry) + } + const mapped = await classifyRuntimeMobileDirectoryEntries(dirPath, entries) return mapped.sort((a, b) => { if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1 @@ -1288,6 +1216,11 @@ export class RuntimeFileCommands { signal?: AbortSignal ): Promise<() => void> { const target = await this.resolveFileExplorerPath(worktreeSelector, '') + const releaseAdmission = runtimeFileWatcherAdmission.claim( + this.host.getRuntimeId(), + target.connectionId, + target.path + ) const open = async (): Promise<{ unsubscribe: () => Promise rootPaths: string[] @@ -1301,16 +1234,7 @@ export class RuntimeFileCommands { } // Why: the RPC layer already threads AbortSignal for local watches; SSH must cancel the remote fs.watch, not wait it out. const close = await provider.watch(target.path, callback, { signal, onTerminalError }) - const rearm = armSshFileExplorerWatchRearm({ - runtimeId: this.host.getRuntimeId(), - connectionId: target.connectionId, - rootPath: target.path, - callback, - onTerminalError, - signal, - initialUnwatch: close - }) - return { unsubscribe: rearm.unsubscribe, rootPaths: [target.path] } + return { unsubscribe: async () => close(), rootPaths: [target.path] } } const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) @@ -1334,15 +1258,30 @@ export class RuntimeFileCommands { finishInstall() } } - const initial = await open() - return registerRuntimeFileWatcherRelease( - this.host.getRuntimeId(), - target.connectionId, - initial.rootPaths, - initial.unsubscribe, - async () => (await open()).unsubscribe, - onTerminalError - ) + let initial: Awaited> + try { + initial = await open() + } catch (error) { + releaseAdmission() + throw error + } + try { + return registerRuntimeFileWatcherRelease( + this.host.getRuntimeId(), + target.connectionId, + initial.rootPaths, + initial.unsubscribe, + async () => (await open()).unsubscribe, + onTerminalError, + releaseAdmission + ) + } catch (error) { + releaseAdmission() + await trackRuntimeFileWatcherUnsubscribe(initial.rootPaths[0], initial.unsubscribe).catch( + () => undefined + ) + throw error + } } async closeFileExplorerWatchersForPath(rootPath: string, connectionId?: string): Promise { @@ -1371,9 +1310,6 @@ export class RuntimeFileCommands { forgetFileExplorerWatchersAfterRemoval(rootPath: string, connectionId?: string): void { const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) - // Why: forget() never runs the lease's unsubscribe, so the re-arm would outlive a deleted - // worktree and re-watch it on the next reconnect. - stopSshFileExplorerWatchRearms(key) const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) if (leases) { for (const lease of Array.from(leases)) { @@ -1403,23 +1339,30 @@ export class RuntimeFileCommands { const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) const fileStats = await stat(filePath) const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - if (mimeType) { - if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) { + const sizeLimit = mimeType ? RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES : MOBILE_FILE_READ_MAX_BYTES + if (fileStats.size > sizeLimit) { + throw new Error('file_too_large') + } + let buffer: Buffer + try { + buffer = (await readNodeFileWithinLimit(filePath, sizeLimit)).buffer + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { throw new Error('file_too_large') } - const buffer = await readFile(filePath) + throw error + } + if (mimeType) { + const imageDimensions = assertRasterImagePreviewWithinLimits(buffer, mimeType) return { content: buffer.toString('base64'), isBinary: true, isImage: true, - mimeType + mimeType, + ...(imageDimensions ? { imageDimensions } : {}) } } - if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - const buffer = await readFile(filePath) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } @@ -1846,7 +1789,12 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - const relativePaths = await provider.listFiles(target.worktree.path) + if (!provider.listMarkdownDocuments) { + throw new Error( + 'Remote Markdown link discovery is unavailable. Reconnect the SSH target and retry.' + ) + } + const relativePaths = await provider.listMarkdownDocuments(target.worktree.path) return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths) } return listMarkdownDocuments(target.worktree.path) @@ -1896,11 +1844,12 @@ export class RuntimeFileCommands { return new Promise((resolvePromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` + assertRuntimeTextSearchAdmission(this.activeRuntimeTextSearches, searchKey) const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) this.activeRuntimeTextSearches.get(searchKey)?.kill() const acc = createAccumulator() - let stdoutBuffer = '' + const stdoutLines = new SearchSubprocessLineAccumulator() let resolved = false let child: ChildProcess | null = null const wslInfo = parseWslPath(authorizedRootPath) @@ -1953,13 +1902,11 @@ export class RuntimeFileCommands { child = nextChild this.activeRuntimeTextSearches.set(searchKey, nextChild) - nextChild.stdout!.setEncoding('utf-8') - const onStdoutData = (chunk: string): void => { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) + const onStdoutData = (chunk: Buffer): void => { + if (!stdoutLines.push(chunk, processLine)) { + acc.truncated = true + child?.kill() + resolveOnce() } } const onStderrData = (): void => { @@ -1967,8 +1914,9 @@ export class RuntimeFileCommands { } const onError = (): void => resolveOnce() const onClose = (): void => { - if (stdoutBuffer) { - processLine(stdoutBuffer) + const trailingLine = stdoutLines.finish() + if (trailingLine !== null) { + processLine(trailingLine) } resolveOnce() } @@ -2167,6 +2115,45 @@ async function isRuntimeDirectoryEntry( return false } +type RuntimeDirectorySourceEntry = { + name: string + isDirectory(): boolean + isSymbolicLink(): boolean +} + +type RuntimeDirectoryClassifier = ( + entry: RuntimeDirectorySourceEntry, + entryPath: string +) => Promise + +export async function classifyRuntimeMobileDirectoryEntries( + dirPath: string, + entries: readonly RuntimeDirectorySourceEntry[], + classify: RuntimeDirectoryClassifier = isRuntimeDirectoryEntry +): Promise { + const mapped: DirEntry[] = [] + for ( + let offset = 0; + offset < entries.length; + offset += RUNTIME_MOBILE_DIRECTORY_CLASSIFICATION_CONCURRENCY + ) { + const batch = entries.slice( + offset, + offset + RUNTIME_MOBILE_DIRECTORY_CLASSIFICATION_CONCURRENCY + ) + mapped.push( + ...(await Promise.all( + batch.map(async (entry) => ({ + name: entry.name, + isDirectory: await classify(entry, join(dirPath, entry.name)), + isSymlink: entry.isSymbolicLink() + })) + )) + ) + } + return mapped +} + function isBinaryBuffer(buffer: Buffer): boolean { const len = Math.min(buffer.length, 8192) for (let i = 0; i < len; i += 1) { @@ -2256,11 +2243,13 @@ async function readLocalTerminalArtifactPreviewFromHandle( handle, RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES + 1 ) + const imageDimensions = assertRasterImagePreviewWithinLimits(buffer, mimeType) return { content: buffer.toString('base64'), isBinary: true, isImage: true, - mimeType + mimeType, + ...(imageDimensions ? { imageDimensions } : {}) } } diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 7aaa8ede56a..a2dd698444f 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -23,6 +23,7 @@ import { mergeLegacyCommitMessageAiIntoSourceControlAi, type ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' +import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables' import type { SourceControlAiOperation } from '../../shared/source-control-ai-types' import type { GitProviderStatusOptions } from '../providers/types' import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' @@ -160,11 +161,26 @@ export type RuntimeGitCommandHost = { resolveRuntimeGitTarget(selector: string): Promise getRuntimeSettings(): GlobalSettings getCommitMessageAgentEnvironment?(): CommitMessageAgentEnvironmentResolvers | undefined + /** + * Live linked-issue read by worktree id. Resolved worktrees come from a + * short-TTL cache, so link/unlink would otherwise lag generation; hosts that + * implement this are authoritative, including the `null` unlinked answer. + * Return `undefined` when metadata is unavailable (store not ready) so the + * caller keeps the resolved worktree's cached value instead of reading it as + * unlinked. + */ + getWorktreeLinkedIssue?(worktreeId: string): number | null | undefined } export class RuntimeGitCommands { constructor(private readonly host: RuntimeGitCommandHost) {} + private linkedIssueForTarget(target: RuntimeGitTarget): number | null | undefined { + const live = this.host.getWorktreeLinkedIssue?.(target.worktree.id) + // Why: `undefined` means the host could not answer, not "unlinked". + return live === undefined ? target.worktree.linkedIssue : live + } + async getRuntimeGitStatus( worktreeSelector: string, options?: GitProviderStatusOptions @@ -615,6 +631,7 @@ export class RuntimeGitCommands { if (!context) { return { success: false, error: 'No staged changes to summarize.' } } + context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target)) return generateCommitMessageFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: target.worktree.path, @@ -634,6 +651,7 @@ export class RuntimeGitCommands { if (!context) { return { success: false, error: 'No staged changes to summarize.' } } + context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target)) const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, this.host.getCommitMessageAgentEnvironment?.(), @@ -738,6 +756,8 @@ export class RuntimeGitCommands { if (!context) { return { success: false, error: 'No branch changes to summarize.' } } + // Why: both SSH and local branches share this context, so one attach covers each. + context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target)) if (target.connectionId) { return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { diff --git a/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts b/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts new file mode 100644 index 00000000000..fea4e93a34b --- /dev/null +++ b/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts @@ -0,0 +1,141 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeMeta } from '../../shared/types' +import type * as GitStatusModule from '../git/status' +import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation' +import type * as WorktreeModule from '../git/worktree' +import { OrcaRuntimeService } from './orca-runtime' + +const mocks = vi.hoisted(() => ({ + listWorktrees: vi.fn(), + getStagedCommitContext: vi.fn(), + generateCommitMessageFromContext: vi.fn(), + resolveCommitMessageSettings: vi.fn() +})) + +vi.mock('../git/worktree', async () => ({ + ...(await vi.importActual('../git/worktree')), + listWorktrees: mocks.listWorktrees +})) + +vi.mock('../git/status', async () => ({ + ...(await vi.importActual('../git/status')), + getStagedCommitContext: mocks.getStagedCommitContext +})) + +vi.mock('../text-generation/commit-message-text-generation', async () => ({ + ...(await vi.importActual( + '../text-generation/commit-message-text-generation' + )), + generateCommitMessageFromContext: mocks.generateCommitMessageFromContext, + resolveCommitMessageSettings: mocks.resolveCommitMessageSettings +})) + +const REPO_ID = 'repo-1' +const STAGED_CONTEXT = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } +const PARAMS = { agentId: 'codex', model: 'gpt-5.4-mini' } + +const tempDirs: string[] = [] + +/** + * Store double narrow enough to drive worktree resolution, so the runtime runs + * its real hydration (`listResolvedWorktrees` → `mergeWorktree` → cache) instead + * of a hand-built worktree fixture carrying `linkedIssue`. + */ +function makeStore(worktreePath: string) { + const worktreeId = `${REPO_ID}::${worktreePath}` + const worktreeMeta: Record = { + [worktreeId]: { + instanceId: worktreeId, + displayName: 'wt', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + } + return { + worktreeId, + updateLinkedIssue: (linkedIssue: number | null): void => { + // Why: mirrors `worktrees:updateMeta`, which persists the link without + // invalidating the runtime's resolved-worktree cache. + worktreeMeta[worktreeId] = { ...worktreeMeta[worktreeId], linkedIssue } + }, + store: { + getRepos: () => [ + { id: REPO_ID, path: worktreePath, displayName: 'repo', badgeColor: 'blue', addedAt: 1 } + ], + getRepo: (id: string) => + id === REPO_ID + ? { id: REPO_ID, path: worktreePath, displayName: 'repo', badgeColor: 'blue', addedAt: 1 } + : undefined, + getAllWorktreeMeta: () => worktreeMeta, + getWorktreeMeta: (id: string) => worktreeMeta[id], + setWorktreeMeta: (id: string, updates: Partial) => { + worktreeMeta[id] = { ...worktreeMeta[id], ...updates } + return worktreeMeta[id] + }, + getSettings: () => ({}) + } + } +} + +async function generatedCommitContext( + runtime: OrcaRuntimeService, + worktreeId: string +): Promise> { + mocks.generateCommitMessageFromContext.mockClear() + await runtime.generateRuntimeCommitMessage(`id:${worktreeId}`) + return mocks.generateCommitMessageFromContext.mock.calls[0][0] +} + +describe('runtime commit-message generation linked-issue freshness', () => { + beforeEach(() => { + mocks.listWorktrees.mockReset() + mocks.getStagedCommitContext.mockReset() + mocks.generateCommitMessageFromContext.mockReset() + mocks.resolveCommitMessageSettings.mockReset() + mocks.getStagedCommitContext.mockResolvedValue(STAGED_CONTEXT) + mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' }) + mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params: PARAMS }) + }) + + afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }) + } + }) + + it('substitutes the linked issue persisted since the last worktree resolution', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-linked-issue-')) + tempDirs.push(worktreePath) + const { store, worktreeId, updateLinkedIssue } = makeStore(worktreePath) + mocks.listWorktrees.mockResolvedValue([ + { + path: worktreePath, + head: 'a'.repeat(40), + branch: 'main', + isBare: false, + isMainWorktree: true + } + ]) + const runtime = new OrcaRuntimeService(store as never) + + // Why: the first generation warms the resolved-worktree cache with the + // unlinked projection, so a stale read would still answer `unlinked` below. + expect(await generatedCommitContext(runtime, worktreeId)).not.toHaveProperty('linkedIssue') + + updateLinkedIssue(321) + expect(await generatedCommitContext(runtime, worktreeId)).toMatchObject({ linkedIssue: 321 }) + + updateLinkedIssue(null) + expect(await generatedCommitContext(runtime, worktreeId)).not.toHaveProperty('linkedIssue') + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 0029ed696bd..4c8670b1977 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -122,12 +122,12 @@ import { const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') +const TEST_MAX_HOOK_GITIGNORE_BYTES = 4 * 1024 * 1024 const removeWorktreeLinkedPathsMock = vi.hoisted(() => vi.fn()) const findExistingWorktreeSymlinkPathsMock = vi.hoisted(() => vi.fn()) const resolveLocalGitUsernameMock = vi.hoisted(() => vi.fn(async () => '')) vi.mock('../ipc/worktree-symlinks', () => ({ - createWorktreeCopiedPaths: vi.fn(), createWorktreeLinkedPaths: vi.fn(), findExistingWorktreeSymlinkPaths: findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock @@ -450,6 +450,8 @@ vi.mock('../hooks', () => ({ ORCA_ROOT_PATH: '/remote/repo', ORCA_WORKTREE_PATH: worktreePath }), + MAX_HOOK_GITIGNORE_BYTES: 4 * 1024 * 1024, + MAX_ISSUE_COMMAND_BYTES: 1024 * 1024, loadHooks: vi.fn().mockReturnValue(null), runHook: vi.fn().mockResolvedValue({ success: true, output: '' }), shouldRunSetupForCreate: vi @@ -1297,7 +1299,7 @@ async function createExplicitAgentStatusHarness(options: { getForegroundProcess: (ptyId: string) => Promise inspectProcess?: ( ptyId: string - ) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> + ) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> confirmForegroundProcess?: (ptyId: string) => Promise title?: string }): Promise<{ @@ -1847,32 +1849,9 @@ describe('OrcaRuntimeService', () => { expect(hasPty).toHaveBeenCalledTimes(4) expect(hasPty).toHaveBeenCalledWith(floatingPtyId) expect(listProcesses).not.toHaveBeenCalled() - // Why: a floating tab's worktree id carries no repoId, so the hydrate repo gate - // must never resolve the inventory for it — #9343 made that read eager and - // regressed this poll path. Keep both halves of the contract asserted. expect(getRepos).not.toHaveBeenCalled() }) - it('hydrates persisted tabs when the store cannot report repos', async () => { - // Why: #9343 read the repo gate as `getRepos?.() ?? []`, so a store that cannot - // report its inventory looked like "every repo is gone" and hydrated nothing — - // every tab vanished. An unavailable list must fail open; only a list the store - // actually returned may prune a dead repo's session key. - const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( - makeWorkspaceSessionWithHeadlessTerminal() - ) - const runtime = new OrcaRuntimeService({ - ...runtimeStore, - getRepos: () => undefined - } as never) - - const tabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) - - expect(tabs.tabs).toEqual([ - expect.objectContaining({ type: 'terminal', parentTabId: 'host-tab' }) - ]) - }) - it('advertises browser screencast only when a renderer window is available', () => { const runtime = createRuntime() electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) @@ -1882,124 +1861,6 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().capabilities).toContain('browser.screencast.v1') }) - it('advertises safe Codex reset-credit RPC support as a static capability', () => { - const runtime = createRuntime() - - expect(runtime.getStatus().capabilities).toContain('accounts.codex-reset-credit.v1') - }) - - it('routes mobile Codex reset consumption through the account mutation coordinator', async () => { - const runtime = createRuntime() - const expectedScope = { - target: { runtime: 'host' as const, wslDistro: null }, - accountId: 'codex-account', - accountRevision: 42, - offerRevision: 'v1:offer' - } - const capturedCodex = { - accounts: [], - activeAccountId: expectedScope.accountId, - activeAccountIdsByRuntime: { host: expectedScope.accountId, wsl: {} } - } - const capturedRateLimits = { - codexTarget: expectedScope.target, - marker: 'captured-before-queue-advanced' - } - const codexAccounts = { - consumeRateLimitResetCredit: vi.fn().mockResolvedValue({ - outcome: 'reset', - scope: expectedScope, - codex: capturedCodex, - rateLimits: capturedRateLimits - }), - listAccounts: vi.fn(() => ({ - accounts: [], - activeAccountId: 'queued-next-account', - activeAccountIdsByRuntime: { host: 'queued-next-account', wsl: {} } - })) - } - const rateLimits = { - consumeCodexRateLimitResetCredit: vi.fn(), - getState: vi.fn(() => ({ - codexTarget: expectedScope.target, - marker: 'after-queue-advanced' - })) - } - runtime.setAccountServices({ - claudeAccounts: { - listAccounts: vi.fn(() => ({ accounts: [], activeAccountId: null })) - }, - codexAccounts, - rateLimits - } as never) - - const result = await runtime.consumeCodexRateLimitResetCredit( - '11111111-1111-4111-8111-111111111111', - expectedScope - ) - - expect(result).toMatchObject({ - outcome: 'reset', - scope: expectedScope, - snapshot: { codex: capturedCodex, rateLimits: capturedRateLimits } - }) - expect(codexAccounts.listAccounts).not.toHaveBeenCalled() - expect(rateLimits.getState).not.toHaveBeenCalled() - expect(codexAccounts.consumeRateLimitResetCredit).toHaveBeenCalledWith( - '11111111-1111-4111-8111-111111111111', - expectedScope - ) - expect(rateLimits.consumeCodexRateLimitResetCredit).not.toHaveBeenCalled() - }) - - it('maps a definite pre-provider rejection into an authoritative current snapshot', async () => { - const runtime = createRuntime() - const expectedScope = { - target: { runtime: 'host' as const, wslDistro: null }, - accountId: 'codex-account', - accountRevision: 42, - offerRevision: 'v1:stale' - } - const codex = { - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - } - const rateLimitState = { - codexTarget: expectedScope.target, - marker: 'current-after-rejection' - } - runtime.setAccountServices({ - claudeAccounts: { - listAccounts: vi.fn(() => ({ accounts: [], activeAccountId: null })) - }, - codexAccounts: { - consumeRateLimitResetCredit: vi.fn().mockResolvedValue({ - status: 'rejectedBeforeProvider', - retryDisposition: 'discardAttempt', - reason: 'offerChanged', - scope: expectedScope, - codex, - rateLimits: rateLimitState - }) - }, - rateLimits: {} - } as never) - - await expect( - runtime.consumeCodexRateLimitResetCredit( - '11111111-1111-4111-8111-111111111111', - expectedScope - ) - ).resolves.toMatchObject({ - status: 'rejectedBeforeProvider', - retryDisposition: 'discardAttempt', - reason: 'offerChanged', - scope: expectedScope, - snapshot: { codex, rateLimits: rateLimitState } - }) - }) - it('advertises headless browser capability when an offscreen backend backs a windowless host', () => { const runtime = createRuntime() runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) @@ -2011,6 +1872,7 @@ describe('OrcaRuntimeService', () => { expect(capabilities).toContain('browser.headless.v1') expect(capabilities).toContain('browser.certificate-trust.v1') }) + it('surfaces live offscreen load failures in headless browser snapshots', () => { const runtime = createRuntime() runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) @@ -3432,7 +3294,10 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).not.toHaveBeenCalled() expect(gitProvider.listWorktrees).toHaveBeenCalledWith('//Server/Share/Repo') - expect(fsProvider.readDir).toHaveBeenCalledWith('\\\\Server\\Share\\Repo\\src') + expect(fsProvider.readDir).toHaveBeenCalledWith('\\\\Server\\Share\\Repo\\src', { + maxEntries: 10_000, + maxRetainedBytes: 4 * 1024 * 1024 + }) expect(gitProvider.getStatus).toHaveBeenCalledWith('//Server/Share/Repo') }) @@ -3499,7 +3364,10 @@ describe('OrcaRuntimeService', () => { } expect(fsProvider.stat).toHaveBeenCalledWith(folderPath) - expect(fsProvider.readDir).toHaveBeenCalledWith('/srv/platform/src') + expect(fsProvider.readDir).toHaveBeenCalledWith('/srv/platform/src', { + maxEntries: 10_000, + maxRetainedBytes: 4 * 1024 * 1024 + }) expect(fsProvider.stat).toHaveBeenCalledWith('/srv/platform/src/app.ts') expect(fsProvider.readFile).toHaveBeenCalledWith('/srv/platform/src/app.ts') }) @@ -5371,6 +5239,12 @@ describe('OrcaRuntimeService', () => { listWorktrees: vi.fn().mockResolvedValue([created]) } const fsProvider = { + stat: vi.fn(async (filePath: string) => { + if (filePath.endsWith('orca.yaml')) { + return { size: 7, type: 'file', mtime: 0 } + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }), readFile: vi.fn().mockResolvedValue({ isBinary: false, content: 'hooks:\n' }), createDir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined) @@ -5528,6 +5402,12 @@ describe('OrcaRuntimeService', () => { listWorktrees: vi.fn().mockResolvedValue([created]) } const fsProvider = { + stat: vi.fn(async (filePath: string) => { + if (filePath.endsWith('orca.yaml')) { + return { size: 7, type: 'file', mtime: 0 } + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }), readFile: vi.fn().mockResolvedValue({ isBinary: false, content: 'hooks:\n' }), createDir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined) @@ -5740,6 +5620,7 @@ describe('OrcaRuntimeService', () => { ] } const fsProvider = { + stat: vi.fn().mockResolvedValue({ size: 32, type: 'file', mtime: 0 }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n setup: pnpm install\n', isBinary: false @@ -5816,6 +5697,7 @@ describe('OrcaRuntimeService', () => { ] } const fsProvider = { + stat: vi.fn().mockResolvedValue({ size: 32, type: 'file', mtime: 0 }), readFile: vi.fn(async (filePath: string) => ({ content: filePath.endsWith('orca.yaml') ? 'scripts:\n setup: pnpm install\n' @@ -5861,6 +5743,49 @@ describe('OrcaRuntimeService', () => { ) }) + it('does not materialize an oversized SSH .gitignore while writing an issue command', async () => { + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + const fsProvider = { + stat: vi.fn().mockResolvedValue({ + size: TEST_MAX_HOOK_GITIGNORE_BYTES + 1, + type: 'file', + mtime: 0 + }), + readFile: vi.fn(), + writeFile: vi.fn().mockResolvedValue(undefined), + createDir: vi.fn().mockResolvedValue(undefined) + } + registerSshFilesystemProvider('ssh-1', fsProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await expect(runtime.writeRepoIssueCommand('id:repo-1', 'Ship it')).resolves.toEqual({ + ok: true + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(fsProvider.readFile).not.toHaveBeenCalled() + expect(fsProvider.writeFile).toHaveBeenCalledOnce() + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/remote/repo/.orca/issue-command', + 'Ship it\n' + ) + }) + it('resolves SSH issue commands from shared orca.yaml and deletes empty overrides', async () => { const remoteStore = { ...store, @@ -5880,6 +5805,7 @@ describe('OrcaRuntimeService', () => { issueCommand: 'claude -p "Fix #{{issue}}"' }) const fsProvider = { + stat: vi.fn().mockResolvedValue({ size: 48, type: 'file', mtime: 0 }), readFile: vi.fn(async (filePath: string) => { if (filePath.endsWith('.orca/issue-command')) { throw Object.assign(new Error('missing'), { code: 'ENOENT' }) @@ -6123,6 +6049,45 @@ describe('OrcaRuntimeService', () => { ) }) + it('pins explicit origin preference on runtime open-by-number work item lookups', async () => { + const originRepo = { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + issueSourcePreference: 'origin' as const + } + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [originRepo], + getRepo: (id: string) => (id === originRepo.id ? originRepo : undefined) + } as never) + const prRepo = { owner: 'acme', repo: 'orca' } + + await runtime.getRepoWorkItem('id:repo-1', 42, 'pr') + await runtime.getRepoWorkItemDetails('id:repo-1', 42, 'pr') + await runtime.getRepoWorkItemByOwnerRepo('id:repo-1', prRepo, 42, 'pr') + + expect(getGitHubWorkItemMock).toHaveBeenCalledWith(TEST_REPO_PATH, 42, 'pr', null, {}, 'origin') + expect(getGitHubWorkItemDetailsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + 'pr', + null, + {}, + 'origin' + ) + // Why: explicit owner/repo already pins identity, so it stays preference-free. + expect(getGitHubWorkItemByOwnerRepoMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + prRepo, + 42, + 'pr', + null + ) + }) + it('routes runtime GitHub PR details and actions through the selected WSL project runtime', async () => { setPlatform('win32') const runtimeStore = { @@ -6220,7 +6185,8 @@ describe('OrcaRuntimeService', () => { 42, 'pr', null, - localGitOptions + localGitOptions, + undefined ) expect(getGitHubWorkItemByOwnerRepoMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -6235,7 +6201,8 @@ describe('OrcaRuntimeService', () => { 42, 'pr', null, - localGitOptions + localGitOptions, + undefined ) expect(getGitHubPRChecksMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -10545,6 +10512,24 @@ describe('OrcaRuntimeService', () => { expect(getForegroundProcess).not.toHaveBeenCalled() }) + it('preserves provider unavailable results during process inspection', async () => { + const inspection = { + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true as const + } + const inspectProcess = vi.fn(async () => inspection) + const getForegroundProcess = vi.fn(async () => null) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + inspectProcess + }) + + await expect(runtime.inspectTerminalProcess(handle)).resolves.toEqual(inspection) + expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + it('calls foreground confirmation with its controller receiver', async () => { const getForegroundProcess = vi.fn(async () => 'powershell.exe') const confirmForegroundProcess = vi.fn( @@ -11054,7 +11039,7 @@ describe('OrcaRuntimeService', () => { expect(internals.ptysById.has('pty-exited-during-start')).toBe(false) }) - it('adopts repeated structured OMP resumes while preserving the exact file locator', async () => { + it('adopts the execution owner canonical surface for repeated structured resumes', async () => { let canonicalOwner: | { claim: AgentSessionExecutionClaim @@ -11093,9 +11078,8 @@ describe('OrcaRuntimeService', () => { const request = { kind: 'explicit' as const, worktree: `id:${TEST_WORKTREE_ID}`, - agent: 'omp' as const, - providerSession: { key: 'session_id' as const, id: 'provider-session-1' }, - ompResumeFilePath: '/custom/omp/project/session.jsonl' + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'provider-session-1' } } const first = await runtime.ensureAgentSession(request) const second = await runtime.ensureAgentSession(request) @@ -11110,9 +11094,9 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledTimes(2) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ - command: expect.stringContaining("'--resume' '/custom/omp/project/session.jsonl'"), + command: expect.stringContaining("'resume' 'provider-session-1'"), agentSessionEnsure: expect.objectContaining({ - claim: expect.objectContaining({ agent: 'omp' }) + claim: expect.objectContaining({ agent: 'codex' }) }) }) ) @@ -21690,35 +21674,6 @@ describe('OrcaRuntimeService', () => { }) }) - it('reuses pane close for live PTYs that do not own a renderer tab', async () => { - const kill = vi.fn(() => true) - const closeTerminalTab = vi.fn(async () => {}) - const runtime = new OrcaRuntimeService(store) - runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never) - runtime.setPtyController({ - write: () => true, - kill, - getForegroundProcess: async () => null, - listProcesses: async () => [ - { - id: 'floating-created-pty', - cwd: TEST_WORKTREE_PATH, - title: 'Claude' - } - ] - }) - runtime.registerPty('floating-created-pty', TEST_WORKTREE_ID) - const [terminal] = (await runtime.listTerminals()).terminals - - await expect(runtime.closeTerminalTab(terminal.handle)).resolves.toEqual({ - handle: terminal.handle, - tabId: terminal.tabId, - ptyKilled: true - }) - expect(kill).toHaveBeenCalledWith('floating-created-pty') - expect(closeTerminalTab).not.toHaveBeenCalled() - }) - it('durably closes every split leaf without a renderer', async () => { const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ @@ -30885,6 +30840,42 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).toHaveBeenCalledTimes(15) }) + it('bounds resolved worktree scanning to a fixed repo worker pool', async () => { + vi.mocked(listWorktrees).mockReset() + const repos = Array.from({ length: 40 }, (_, index) => ({ + id: `repo-${index}`, + path: `/tmp/repo-${index}`, + displayName: `repo-${index}`, + badgeColor: 'blue' as const, + addedAt: 1 + })) + const gate = deferred() + let inFlight = 0 + let peak = 0 + vi.mocked(listWorktrees).mockImplementation(async (repoPath) => { + inFlight += 1 + peak = Math.max(peak, inFlight) + await gate.promise + inFlight -= 1 + return [makeWorktreeInfo(repoPath)] + }) + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => repos, + getRepo: (id: string) => repos.find((candidate) => candidate.id === id), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined + } as never) + + const listing = runtime.listManagedWorktrees() + await vi.waitFor(() => expect(listWorktrees).toHaveBeenCalledTimes(8)) + expect(peak).toBe(8) + gate.resolve() + await expect(listing).resolves.toMatchObject({ totalCount: repos.length }) + expect(listWorktrees).toHaveBeenCalledTimes(repos.length) + expect(peak).toBe(8) + }) + it('worktree scan cache: shares one in-flight repo scan across concurrent consumers', async () => { vi.mocked(listWorktrees).mockClear() const pending = deferred[]>() @@ -34515,10 +34506,20 @@ describe('OrcaRuntimeService', () => { expect(updateSettings).not.toHaveBeenCalled() }) - it('routes runtime GitHub PR base git calls through the selected WSL project runtime', async () => { + it('threads explicit origin preference through runtime WSL PR base resolution', async () => { setPlatform('win32') + const localRepo = { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + issueSourcePreference: 'origin' as const + } const runtimeStore = { ...store, + getRepos: () => [localRepo], + getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined), getProjects: () => [ { id: 'project-1', @@ -34546,8 +34547,18 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'origin\n', stderr: '' } } + if (args[0] === 'remote' && args[1] === 'get-url') { + if (args[2] !== 'origin' && args[2] !== 'upstream') { + throw new Error(`unexpected remote: ${String(args[2])}`) + } + const url = + args[2] === 'origin' + ? 'git@github.com:org/repo.git' + : 'git@github.com:org/upstream-repo.git' + return { stdout: `${url}\n`, stderr: '' } + } if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } + return { stdout: 'origin\nupstream\n', stderr: '' } } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } @@ -34575,11 +34586,6 @@ describe('OrcaRuntimeService', () => { headSha: 'pr-head-sha', branchNameOverride: 'feature/add-feature' }) - expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: TEST_REPO_PATH, - timeout: 15_000, - wslDistro: 'Ubuntu' - }) expect(gitSpy).toHaveBeenCalledWith( [ 'fetch', @@ -34592,6 +34598,12 @@ describe('OrcaRuntimeService', () => { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' }) + // Why: the explicit origin preference must short-circuit before any + // identity probe, so no remote — not just upstream — gets a get-url. + expect(gitSpy).not.toHaveBeenCalledWith( + ['remote', 'get-url', expect.anything()], + expect.anything() + ) } finally { gitSpy.mockRestore() } @@ -34604,7 +34616,8 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + issueSourcePreference: 'origin' as const } const runtimeStore = { ...store, @@ -34617,7 +34630,7 @@ describe('OrcaRuntimeService', () => { return { stdout: `${ORIGIN_REMOTE_URL}\n`, stderr: '' } } if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } + return { stdout: 'origin\nupstream\n', stderr: '' } } if ( args[0] === 'rev-parse' && @@ -34648,6 +34661,13 @@ describe('OrcaRuntimeService', () => { branchNameOverride: 'contributor/fix' }) expect(provider.fetchGitHubPullRequestHead).toHaveBeenCalledWith('/remote/repo', 'origin', 42) + expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( + '/remote/repo', + 42, + 'ssh-1', + {}, + 'origin' + ) expect(provider.exec).not.toHaveBeenCalledWith( expect.arrayContaining(['fetch']), '/remote/repo' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1f0eb503d00..783a16866a1 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -92,11 +92,12 @@ import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fe import { createHash, randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' -import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' +import { mkdir, opendir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' +import { OrchestrationMessageWaiterRegistry } from './orchestration-message-waiter-registry' import type { Automation, AutomationCreateInput, @@ -191,6 +192,11 @@ import { } from '../../shared/runtime-navigation' import type { SshConnectionState } from '../../shared/ssh-types' import { getPublicSshState } from './public-ssh-state' +import { + RuntimeSshRelayRecoveryGenerations, + type RuntimeSshRelayRecoveryGenerationLease +} from './runtime-ssh-relay-recovery-generations' +import { RuntimeOperationGenerations } from './runtime-operation-generations' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' import type { LinearCurrentIssueContextHints, @@ -243,6 +249,7 @@ import { import { parsePtySessionId } from '../../shared/pty-session-id-format' import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' import { isFolderRepo } from '../../shared/repo-kind' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { buildSetupRunnerCommand, @@ -573,6 +580,14 @@ import type { PRRefreshOutcome } from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' +import { + readSetupScriptImportFile, + SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, + SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS +} from '../setup-script-import-file' +import { MAX_ORCA_YAML_BYTES, MAX_ORCA_YAML_CODE_UNITS } from '../../shared/orca-yaml-file-limit' +import { readFilesystemProviderBoundedText } from '../filesystem-provider-bounded-text' +import { readLocalFilesystemDirectory } from '../ipc/filesystem-directory-reader' import type { CreateHostedReviewInput, CreateHostedReviewResult, @@ -775,6 +790,8 @@ import { hasUnrecognizedOrcaYamlKeys, hasHooksFile, loadHooks, + MAX_HOOK_GITIGNORE_BYTES, + MAX_ISSUE_COMMAND_BYTES, parseOrcaYaml, readIssueCommand, runHook, @@ -793,6 +810,7 @@ import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from '../ipc/worktree-symlinks' +import { formatWorktreeIncludeCopyWarning } from '../ipc/worktree-include-copy-budget' import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' import { deleteWorktreeHistoryDir } from '../terminal-history' import { @@ -1464,7 +1482,7 @@ type RuntimePtyController = { getForegroundProcess(ptyId: string): Promise inspectProcess?( ptyId: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> confirmForegroundProcess?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise @@ -1693,14 +1711,6 @@ type TerminalWaiter = { abortCleanup: (() => void) | null } -type MessageWaiter = { - handle: string - typeFilter: string[] | undefined - resolve: (result: void) => void - timeout: NodeJS.Timeout | null - abortCleanup: (() => void) | null -} - function omitUndefinedProperties>(value: T): Partial { return Object.fromEntries( Object.entries(value).filter(([, entry]) => entry !== undefined) @@ -2531,7 +2541,7 @@ export class OrcaRuntimeService { private resolvedWorktreeCache: ResolvedWorktreeCache | null = null private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null private resolvedWorktreeGeneration = 0 - private worktreeScanGenerations = new Map() + private readonly worktreeScanGenerations = new RuntimeOperationGenerations() private worktreeScanCache = new Map() private worktreeScanInFlight = new Map() private cloneInFlightByPath = new Map>() @@ -2539,7 +2549,7 @@ export class OrcaRuntimeService { private ptyForegroundAgentRefreshes = new Map() private ptyDelayedForegroundSnapshotTitleObservations = new Map() private _orchestrationDb: OrchestrationDb | null = null - private messageWaitersByHandle = new Map>() + private readonly messageWaiters = new OrchestrationMessageWaiterRegistry() // Why: mobile clients subscribe to terminal output via terminal.subscribe. // These listeners fire on every onPtyData call, enabling real-time streaming // without polling. Keyed by ptyId for O(1) lookup per data event. @@ -2610,8 +2620,7 @@ export class OrcaRuntimeService { private providerVisibleStateByPtyId = new Map() private providerVisibleRetryAtByPtyId = new Map() private providerSnapshotsWithLiveModeTransition = new WeakSet() - private ptyLifecycleGenerationById = new Map() - private nextPtyLifecycleGeneration = 1 + private readonly ptyLifecycleGenerations = new RuntimeOperationGenerations() private recentPtyPathCandidatesById = new Map() // Why: candidates only feed mobile file-tap provenance; desktop-only // sessions skip the 3-regex extraction on every PTY chunk until a @@ -2915,7 +2924,7 @@ export class OrcaRuntimeService { | null private readonly agentSessionClaimSigner: AgentSessionClaimSigner private readonly agentSessionCreateOperations = new Map() - private sshRelayRecoveryGenerationByTargetId = new Map() + private readonly sshRelayRecoveryGenerations = new RuntimeSshRelayRecoveryGenerations() private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -3612,33 +3621,32 @@ export class OrcaRuntimeService { // Why: SSH state changes originate in main's ssh handlers, not in runtime // methods, so they need a public entry point onto the client-event stream. notifySshStateChanged(targetId: string, state: SshConnectionState): void { - this.bumpSshRelayRecoveryGeneration(targetId) + this.sshRelayRecoveryGenerations.invalidate(targetId) this.invalidateSshWorktreeScanCache(targetId) this.emitClientEvent({ type: 'sshStateChanged', targetId, state: getPublicSshState(state)! }) } notifySshRelayReady(targetId: string): void { - const generation = this.bumpSshRelayRecoveryGeneration(targetId) - void this.publishRecoveredSshMobileSessionTabs(targetId, generation).catch((error) => { - if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { - return - } - console.warn('[runtime] failed to publish recovered SSH session tabs', { - targetId, - error + const generationLease = this.sshRelayRecoveryGenerations.begin(targetId) + if (!generationLease) { + return + } + void this.publishRecoveredSshMobileSessionTabs(targetId, generationLease) + .catch((error) => { + if (!generationLease.isCurrent()) { + return + } + console.warn('[runtime] failed to publish recovered SSH session tabs', { + targetId, + error + }) }) - }) - } - - private bumpSshRelayRecoveryGeneration(targetId: string): number { - const generation = (this.sshRelayRecoveryGenerationByTargetId.get(targetId) ?? 0) + 1 - this.sshRelayRecoveryGenerationByTargetId.set(targetId, generation) - return generation + .finally(() => generationLease.release()) } private async publishRecoveredSshMobileSessionTabs( targetId: string, - generation: number + generationLease: RuntimeSshRelayRecoveryGenerationLease ): Promise { const repoIds = new Set( (this.store?.getRepos() ?? []) @@ -3670,7 +3678,7 @@ export class OrcaRuntimeService { }) } await this.refreshMobileSessionPtyRecords() - if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { + if (!generationLease.isCurrent()) { return } for (const worktreeId of worktreeIds) { @@ -7066,7 +7074,18 @@ export class OrcaRuntimeService { private readonly gitCommands = new RuntimeGitCommands({ resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), getRuntimeSettings: () => this.requireStore().getSettings() as GlobalSettings, - getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined + getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined, + // Why: resolved worktrees are cached for a second, so link/unlink would lag + // generation; meta is keyed by the same id the resolver returns. + getWorktreeLinkedIssue: (worktreeId) => { + const store = this.store + // Why: an unreadable store is "unknown", not "unlinked" — undefined keeps + // the resolver's cached linkedIssue instead of suppressing {linkedIssue}. + if (!store?.getWorktreeMeta) { + return undefined + } + return store.getWorktreeMeta(worktreeId)?.linkedIssue ?? null + } }) getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] = @@ -8465,17 +8484,15 @@ export class OrcaRuntimeService { } private getPtyLifecycleGeneration(ptyId: string): number { - const existing = this.ptyLifecycleGenerationById.get(ptyId) - if (existing !== undefined) { - return existing - } - const generation = this.nextPtyLifecycleGeneration++ - this.ptyLifecycleGenerationById.set(ptyId, generation) - return generation + return this.ptyLifecycleGenerations.current(ptyId) + } + + private isPtyLifecycleGenerationCurrent(ptyId: string, generation: number): boolean { + return this.ptyLifecycleGenerations.isCurrent(ptyId, generation) } private advancePtyLifecycleGeneration(ptyId: string): void { - this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++) + this.ptyLifecycleGenerations.advance(ptyId) // Why: a provider response belongs to the process generation that issued // it; a respawn must neither reuse its frame nor join its in-flight call. this.providerBufferAcquisitionsByPtyId.delete(ptyId) @@ -9327,7 +9344,7 @@ export class OrcaRuntimeService { // Why: daemon PTYs survive an app relaunch before any renderer mounts. // Mobile still needs their retained history without navigating desktop. const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts) - if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { + if (!snapshot || !this.isPtyLifecycleGenerationCurrent(ptyId, generation)) { return null } const snapshotModeTracker = new TerminalKittyKeyboardModeTracker() @@ -9465,7 +9482,7 @@ export class OrcaRuntimeService { { scrollbackRows: 0 }, { timeoutMs: VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS } ) - if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { + if (!snapshot || !this.isPtyLifecycleGenerationCurrent(ptyId, generation)) { this.providerVisibleRetryAtByPtyId.set(ptyId, Date.now() + VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS) return null } @@ -9479,7 +9496,7 @@ export class OrcaRuntimeService { } } const lines = await this.parseVisibleSnapshotLines(snapshot) - if (this.getPtyLifecycleGeneration(ptyId) !== generation) { + if (!this.isPtyLifecycleGenerationCurrent(ptyId, generation)) { return null } const visibleState: RuntimeVisibleTerminalState = { @@ -9505,7 +9522,7 @@ export class OrcaRuntimeService { await state.writeChain if ( this.headlessTerminals.get(ptyId) !== state || - this.getPtyLifecycleGeneration(ptyId) !== generation + !this.isPtyLifecycleGenerationCurrent(ptyId, generation) ) { return null } @@ -9740,16 +9757,15 @@ export class OrcaRuntimeService { // before the first-ever connect no longer yields candidates. for (const [ptyId, buffer] of this.recentPtyOutputById) { let candidates = this.recentPtyPathCandidatesById.get(ptyId) - const { chunks, headChunkIsPartial } = buffer.retainedChunks() - for (let index = 0; index < chunks.length; index += 1) { + buffer.forEachRetainedChunk((chunk, index, headChunkIsPartial) => { if (index === 0 && headChunkIsPartial) { // A pre-sliced over-window chunk was already extracted eagerly at // append time (while its original text was intact); replaying its // truncated remainder would mint or drop candidates spuriously. - continue + return } - candidates = appendRecentPtyPathCandidates(candidates, chunks[index]!) - } + candidates = appendRecentPtyPathCandidates(candidates, chunk) + }) if (candidates) { this.recentPtyPathCandidatesById.set(ptyId, candidates) } @@ -15195,21 +15211,7 @@ export class OrcaRuntimeService { if (!dirStat.isDirectory()) { throw new Error(`${dirPath} is not a directory`) } - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = entries - .filter((entry) => entry.name !== '.' && entry.name !== '..') - .map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - })) - mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - return { resolvedPath: dirPath, entries: mapped } + return { resolvedPath: dirPath, entries: await readLocalFilesystemDirectory(dirPath) } } async isGitAvailable(): Promise { @@ -15482,9 +15484,13 @@ export class OrcaRuntimeService { if (!existingStat.isDirectory()) { return { error: `"${trimmedName}" already exists at this location and is not a folder.` } } - const entries = await readdir(targetPath) - if (entries.length > 0) { - return { error: `"${trimmedName}" already exists at this location and is not empty.` } + const directory = await opendir(targetPath) + try { + if ((await directory.read()) !== null) { + return { error: `"${trimmedName}" already exists at this location and is not empty.` } + } + } finally { + await directory.close().catch(() => {}) } } else { await mkdir(targetPath, { recursive: false }) @@ -15815,17 +15821,27 @@ export class OrcaRuntimeService { } const repo = await this.resolveRepoSelector(repoSelector) this.store.removeProject(repo.id) - this.terminalTopologyRevisionByRepoId.delete(repo.id) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(repo.id) + this.notifyRepoStoreChanged(repo.id) invalidateAuthorizedRootsCache() - this.notifyReposChanged() return { removed: true } } + notifyRepoStoreChanged(repoId: string): void { + const removed = !this.store?.getRepo(repoId) + if (removed) { + this.terminalTopologyRevisionByRepoId.delete(repoId) + } + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repoId) + if (removed) { + this.worktreeScanGenerations.forget(repoId) + } + this.notifyReposChanged() + } + async inspectTerminalProcess( terminalSelector: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> { const leaf = this.resolveLiveLeafForHandle(terminalSelector) if (!leaf?.ptyId || !this.ptyController) { throw new Error('terminal_gone') @@ -16132,12 +16148,15 @@ export class OrcaRuntimeService { type?: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) + // Why: open-by-number must pin the same source the list and start-point use, + // else a fork and its upstream sharing a PR number resolve to different PRs. return getWorkItem( repo.path, number, type, repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) + this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, + repo.issueSourcePreference ) } @@ -16169,7 +16188,8 @@ export class OrcaRuntimeService { number, type, repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) + this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, + repo.issueSourcePreference ) } @@ -17208,8 +17228,12 @@ export class OrcaRuntimeService { } } try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - const hooks = result.isBinary ? null : parseOrcaYaml(result.content) + const result = await readFilesystemProviderBoundedText( + fsProvider, + joinWorktreeRelativePath(repo.path, 'orca.yaml'), + { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } + ) + const hooks = result.kind === 'text' ? parseOrcaYaml(result.content) : null return { hasHooksFile: Boolean(hooks), hooks, @@ -17257,11 +17281,19 @@ export class OrcaRuntimeService { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - if (result.isBinary) { + const result = await readFilesystemProviderBoundedText( + fsProvider, + joinWorktreeRelativePath(repo.path, 'orca.yaml'), + { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } + ) + if (result.kind === 'binary') { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } - return { hasHooks: true, hooks: parseOrcaYaml(result.content), mayNeedUpdate: false } + return { + hasHooks: true, + hooks: result.kind === 'text' ? parseOrcaYaml(result.content) : null, + mayNeedUpdate: false + } } catch { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } @@ -17290,15 +17322,18 @@ export class OrcaRuntimeService { return null } try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? null : result.content + const result = await readFilesystemProviderBoundedText(fsProvider, filePath, { + maxBytes: SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, + maxCodeUnits: SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS + }) + return result.kind === 'text' ? result.content : null } catch { return null } } try { - return await readFile(filePath, 'utf-8') + return await readSetupScriptImportFile(filePath) } catch (error) { if (!isENOENT(error)) { console.warn('[runtime] Failed to inspect setup script import candidate:', error) @@ -17356,11 +17391,11 @@ export class OrcaRuntimeService { issueCommandPath: string ): Promise { try { - const result = await fsProvider.readFile(issueCommandPath) - if (result.isBinary) { - return null - } - return result.content.trim() || null + const result = await readFilesystemProviderBoundedText(fsProvider, issueCommandPath, { + maxBytes: MAX_ISSUE_COMMAND_BYTES, + maxCodeUnits: MAX_ISSUE_COMMAND_BYTES + }) + return result.kind === 'text' ? result.content.trim() || null : null } catch { return null } @@ -17371,8 +17406,12 @@ export class OrcaRuntimeService { repoPath: string ): Promise { try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, 'orca.yaml')) - if (result.isBinary) { + const result = await readFilesystemProviderBoundedText( + fsProvider, + joinWorktreeRelativePath(repoPath, 'orca.yaml'), + { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } + ) + if (result.kind !== 'text') { return null } return parseOrcaYaml(result.content)?.issueCommand?.trim() || null @@ -17418,9 +17457,12 @@ export class OrcaRuntimeService { options: { required?: boolean } = {} ): Promise { const gitignorePath = joinWorktreeRelativePath(repoPath, '.gitignore') - let result: Awaited> + let result: Awaited> try { - result = await fsProvider.readFile(gitignorePath) + result = await readFilesystemProviderBoundedText(fsProvider, gitignorePath, { + maxBytes: MAX_HOOK_GITIGNORE_BYTES, + maxCodeUnits: MAX_HOOK_GITIGNORE_BYTES + }) } catch (error) { if (!isENOENT(error)) { if (options.required) { @@ -17439,9 +17481,13 @@ export class OrcaRuntimeService { } return } - if (result.isBinary) { + if (result.kind !== 'text') { if (options.required) { - throw new Error('Remote .gitignore is binary; cannot verify .orca is ignored') + throw new Error( + result.kind === 'binary' + ? 'Remote .gitignore is binary; cannot verify .orca is ignored' + : 'Remote .gitignore exceeds the supported size limit' + ) } return } @@ -18935,12 +18981,21 @@ export class OrcaRuntimeService { repo.path, localWorktreeGitOptions ) + let includeCopyWarning: string | undefined if (worktreeIncludePaths.length > 0) { - await createWorktreeCopiedPaths(repo.path, created.path, worktreeIncludePaths) + const skippedIncludePaths = await createWorktreeCopiedPaths( + repo.path, + created.path, + worktreeIncludePaths + ) + includeCopyWarning = formatWorktreeIncludeCopyWarning(skippedIncludePaths) + if (includeCopyWarning) { + console.warn(`[worktree-include] ${includeCopyWarning}`) + } } let setup: CreateWorktreeResult['setup'] - let warning: string | undefined + let warning: string | undefined = includeCopyWarning // Why: CLI-created worktrees do not have a renderer preview to mismatch // against. Trust is granted by the direct CLI invocation (`--run-hooks`), // so loading the setup hook from the created worktree is intentional here. @@ -18994,8 +19049,9 @@ export class OrcaRuntimeService { } } else if (hooks?.scripts.setup && effectiveDecision !== 'skip') { // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. - warning = `orca.yaml setup hook skipped for ${worktreePath}; pass --setup run to run it.` - console.warn(`[hooks] ${warning}`) + const setupSkipped = `orca.yaml setup hook skipped for ${worktreePath}; pass --setup run to run it.` + warning = warning ? `${warning} Also ${setupSkipped}` : setupSkipped + console.warn(`[hooks] ${setupSkipped}`) } this.invalidateResolvedWorktreeCache() @@ -20123,11 +20179,12 @@ export class OrcaRuntimeService { const gitExec = sshGitProvider ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) - // Why: one shared resolver for local and SSH so origin-vs-upstream cannot - // diverge by surface; it prefers the remote hosting the PR's project. + // Why: one resolver keeps source preference and hosting identity aligned + // across local, WSL, and SSH worktree creation. const resolveRemote = (): Promise => resolveGitHubReviewHeadRemote({ repoPath: repo.path, + issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, localGitOptions: localWorktreeGitOptions, gitExec @@ -20157,6 +20214,7 @@ export class OrcaRuntimeService { headRefName: args.headRefName, baseRefName: args.baseRefName, isCrossRepository: args.isCrossRepository, + issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, localGitOptions: localWorktreeGitOptions, gitExec, @@ -24733,8 +24791,10 @@ export class OrcaRuntimeService { getAgentLaunchPlatformForRepo(repo, projectRuntimeByRepoId.get(repo.id)) ]) ) - const perRepoWorktrees = await Promise.all( - repos.map(async (repo) => { + const perRepoWorktrees = await mapWithConcurrency( + repos, + RESOLVED_WORKTREE_REPO_CONCURRENCY, + async (repo) => { if (isFolderRepo(repo)) { return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({ ...worktree, @@ -24791,7 +24851,7 @@ export class OrcaRuntimeService { comment: merged.comment } }) - }) + } ) const worktrees = projectResolvedWorktreeLineage( perRepoWorktrees.flat(), @@ -24855,7 +24915,7 @@ export class OrcaRuntimeService { projectRuntimeByRepoId?: ReadonlyMap ): Promise { const now = Date.now() - const generation = this.worktreeScanGenerations.get(repo.id) ?? 0 + const generation = this.worktreeScanGenerations.current(repo.id) const projectRuntime = projectRuntimeByRepoId ? projectRuntimeByRepoId.get(repo.id) : !repo.connectionId @@ -24886,7 +24946,7 @@ export class OrcaRuntimeService { const result = await promise if ( result.ok && - generation === (this.worktreeScanGenerations.get(repo.id) ?? 0) && + this.worktreeScanGenerations.isCurrent(repo.id, generation) && this.worktreeScanInFlight.get(repo.id)?.promise === promise ) { this.worktreeScanCache.set(repo.id, { @@ -24966,7 +25026,7 @@ export class OrcaRuntimeService { } private invalidateWorktreeScanCacheForRepo(repoId: string): void { - this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1) + this.worktreeScanGenerations.advance(repoId) this.worktreeScanCache.delete(repoId) this.worktreeScanInFlight.delete(repoId) } @@ -24977,7 +25037,7 @@ export class OrcaRuntimeService { repos.filter((repo) => repo.connectionId === targetId).map((repo) => repo.id) ) for (const repoId of affectedRepoIds) { - this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1) + this.worktreeScanGenerations.advance(repoId) this.worktreeScanCache.delete(repoId) this.worktreeScanInFlight.delete(repoId) } @@ -25455,6 +25515,7 @@ export class OrcaRuntimeService { this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) + this.ptyLifecycleGenerations.forget(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { // Why: pruning can remove a PTY without onPtyExit firing; release this leader's agent team so it doesn't leak. @@ -26797,84 +26858,14 @@ export class OrcaRuntimeService { // Why: wake blocking orchestration.check --wait calls on this handle so they return the new message immediately instead of polling. notifyMessageArrived(handle: string, messageType?: string): void { - const waiters = this.messageWaitersByHandle.get(handle) - if (!waiters || waiters.size === 0) { - return - } - for (const waiter of [...waiters]) { - // Why: don't wake a coordinator waiting for worker_done/escalation on heartbeat noise it would misread as idleness. - if (messageType && waiter.typeFilter && !waiter.typeFilter.includes(messageType)) { - continue - } - this.resolveMessageWaiter(waiter) - } + this.messageWaiters.notify(handle, messageType) } waitForMessage( handle: string, options?: { typeFilter?: string[]; timeoutMs?: number; signal?: AbortSignal } ): Promise { - return new Promise((resolve) => { - const timeoutMs = options?.timeoutMs ?? MESSAGE_WAIT_DEFAULT_TIMEOUT_MS - - const waiter: MessageWaiter = { - handle, - typeFilter: options?.typeFilter, - resolve, - timeout: null, - abortCleanup: null - } - - // Why: on caller abort (RPC socket closed — design doc §3.1), resolve now to release the long-poll slot instead of waiting out timeoutMs. - const signal = options?.signal - const onAbort = (): void => { - this.removeMessageWaiter(waiter) - resolve() - } - if (signal) { - if (signal.aborted) { - resolve() - return - } - waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) - signal.addEventListener('abort', onAbort, { once: true }) - } - - waiter.timeout = setTimeout(() => { - this.removeMessageWaiter(waiter) - resolve() - }, timeoutMs) - - let waiters = this.messageWaitersByHandle.get(handle) - if (!waiters) { - waiters = new Set() - this.messageWaitersByHandle.set(handle, waiters) - } - waiters.add(waiter) - }) - } - - private resolveMessageWaiter(waiter: MessageWaiter): void { - this.removeMessageWaiter(waiter) - waiter.resolve() - } - - private removeMessageWaiter(waiter: MessageWaiter): void { - if (waiter.timeout) { - clearTimeout(waiter.timeout) - waiter.timeout = null - } - if (waiter.abortCleanup) { - waiter.abortCleanup() - waiter.abortCleanup = null - } - const waiters = this.messageWaitersByHandle.get(waiter.handle) - if (waiters) { - waiters.delete(waiter) - if (waiters.size === 0) { - this.messageWaitersByHandle.delete(waiter.handle) - } - } + return this.messageWaiters.wait(handle, options) } private buildPtyTerminalSummary( @@ -30387,6 +30378,7 @@ const WORKTREE_SCAN_CACHE_TTL_MS = 30_000 // these (crash-cluster diagnostics, 2026-07). const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 +const RESOLVED_WORKTREE_REPO_CONCURRENCY = 8 export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { return !repo.connectionId && isAgentScratchRepoRootPath(repo.path) @@ -31819,7 +31811,6 @@ async function assertTerminalInputWithinLimitWithYield(text: string | undefined) const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 const TUI_IDLE_POLL_INTERVAL_MS = 2000 const TUI_IDLE_QUIESCENCE_MS = 3000 -const MESSAGE_WAIT_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000 const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' const GEMINI_IDLE_PREFIX = '\u25c7' diff --git a/src/main/runtime/orchestration-message-waiter-registry.test.ts b/src/main/runtime/orchestration-message-waiter-registry.test.ts new file mode 100644 index 00000000000..ee8ed7afb70 --- /dev/null +++ b/src/main/runtime/orchestration-message-waiter-registry.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + MAX_ORCHESTRATION_MESSAGE_WAITERS, + MAX_ORCHESTRATION_MESSAGE_WAITERS_PER_HANDLE, + MAX_ORCHESTRATION_MESSAGE_WAITER_HANDLE_BYTES, + MAX_ORCHESTRATION_MESSAGE_WAITER_RETAINED_HANDLE_BYTES, + OrchestrationMessageWaiterLimitError, + OrchestrationMessageWaiterRegistry +} from './orchestration-message-waiter-registry' + +const BOUNDS = { + maxWaiters: 3, + maxWaitersPerHandle: 2, + maxHandleBytes: 8, + maxRetainedHandleBytes: 10 +} + +describe('OrchestrationMessageWaiterRegistry', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('caps global waiters and admits again after settlement', async () => { + const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) + const first = registry.wait('a') + const second = registry.wait('b') + const third = registry.wait('c') + + expect(() => registry.wait('d')).toThrowError(expect.objectContaining({ reason: 'global' })) + registry.notify('a') + await first + const replacement = registry.wait('d') + expect(registry.evidence()).toEqual({ + waiters: 3, + handles: 3, + retainedHandleBytes: 3 + }) + + registry.close() + await Promise.all([second, third, replacement]) + }) + + it('caps waiters per handle without blocking another handle', async () => { + const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) + const first = registry.wait('same') + const second = registry.wait('same') + + expect(() => registry.wait('same')).toThrowError( + expect.objectContaining({ reason: 'per-handle' }) + ) + const other = registry.wait('other') + expect(registry.evidence()).toEqual({ + waiters: 3, + handles: 2, + retainedHandleBytes: 9 + }) + + registry.close() + await Promise.all([first, second, other]) + }) + + it('rejects oversized handles without retaining them', async () => { + const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) + + expect(() => registry.wait('🌊🌊🌊')).toThrow(OrchestrationMessageWaiterLimitError) + expect(() => registry.wait('🌊🌊🌊')).toThrowError( + expect.objectContaining({ reason: 'handle-bytes' }) + ) + expect(registry.evidence()).toEqual({ + waiters: 0, + handles: 0, + retainedHandleBytes: 0 + }) + + const controller = new AbortController() + controller.abort() + await expect(registry.wait('🌊🌊🌊', { signal: controller.signal })).resolves.toBeUndefined() + }) + + it('accounts for each unique handle key once and caps aggregate key bytes', async () => { + const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) + const first = registry.wait('123456') + const sameHandle = registry.wait('123456') + const second = registry.wait('abcd') + + expect(registry.evidence()).toEqual({ + waiters: 3, + handles: 2, + retainedHandleBytes: 10 + }) + registry.notify('abcd') + await second + expect(() => registry.wait('abcde')).toThrowError( + expect.objectContaining({ reason: 'retained-handle-bytes' }) + ) + + registry.close() + await Promise.all([first, sameHandle]) + }) + + it('releases all key accounting under unique-handle churn', async () => { + const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) + + for (let index = 0; index < 10_000; index += 1) { + const handle = String(index) + const wait = registry.wait(handle) + registry.notify(handle) + await wait + } + + expect(registry.evidence()).toEqual({ + waiters: 0, + handles: 0, + retainedHandleBytes: 0 + }) + }) + + it('preserves type-filter wake behavior and deduplicates filters', async () => { + const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) + let statusResolved = false + let workerResolved = false + const status = registry.wait('same', { + typeFilter: ['status', 'status'] + }) + const worker = registry.wait('same', { + typeFilter: ['worker_done'] + }) + void status.then(() => { + statusResolved = true + }) + void worker.then(() => { + workerResolved = true + }) + + expect(registry.notify('same', 'status')).toBe(1) + await status + expect(statusResolved).toBe(true) + expect(workerResolved).toBe(false) + expect(registry.evidence()).toEqual({ + waiters: 1, + handles: 1, + retainedHandleBytes: 4 + }) + + expect(registry.notify('same')).toBe(1) + await worker + }) + + it('removes timeout and abort state after notification', async () => { + vi.useFakeTimers() + const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const wait = registry.wait('a', { signal: controller.signal, timeoutMs: 100 }) + + registry.notify('a') + await wait + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(vi.getTimerCount()).toBe(0) + expect(registry.evidence()).toEqual({ + waiters: 0, + handles: 0, + retainedHandleBytes: 0 + }) + }) + + it('cleans up exactly on timeout', async () => { + vi.useFakeTimers() + const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const wait = registry.wait('abc', { signal: controller.signal, timeoutMs: 100 }) + + await vi.advanceTimersByTimeAsync(100) + await wait + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(registry.evidence()).toEqual({ + waiters: 0, + handles: 0, + retainedHandleBytes: 0 + }) + }) + + it('cleans up exactly on abort and does not register an already-aborted signal', async () => { + const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const wait = registry.wait('abc', { signal: controller.signal }) + + controller.abort() + await wait + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(registry.evidence()).toEqual({ + waiters: 0, + handles: 0, + retainedHandleBytes: 0 + }) + + const alreadyAborted = new AbortController() + alreadyAborted.abort() + const addListener = vi.spyOn(alreadyAborted.signal, 'addEventListener') + await registry.wait('abc', { signal: alreadyAborted.signal }) + expect(addListener).not.toHaveBeenCalled() + expect(registry.evidence().waiters).toBe(0) + }) + + it('settles every waiter and releases all accounting on close', async () => { + vi.useFakeTimers() + const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const first = registry.wait('first') + const sameHandle = registry.wait('first', { signal: controller.signal }) + const second = registry.wait('other') + + registry.close() + await Promise.all([first, sameHandle, second]) + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(vi.getTimerCount()).toBe(0) + expect(registry.evidence()).toEqual({ + waiters: 0, + handles: 0, + retainedHandleBytes: 0 + }) + await expect(registry.wait('later')).resolves.toBeUndefined() + expect(registry.evidence().waiters).toBe(0) + }) + + it('publishes explicit production bounds', () => { + expect(MAX_ORCHESTRATION_MESSAGE_WAITERS).toBe(1_024) + expect(MAX_ORCHESTRATION_MESSAGE_WAITERS_PER_HANDLE).toBe(64) + expect(MAX_ORCHESTRATION_MESSAGE_WAITER_HANDLE_BYTES).toBe(64 * 1024) + expect(MAX_ORCHESTRATION_MESSAGE_WAITER_RETAINED_HANDLE_BYTES).toBe(1024 * 1024) + }) +}) diff --git a/src/main/runtime/orchestration-message-waiter-registry.ts b/src/main/runtime/orchestration-message-waiter-registry.ts new file mode 100644 index 00000000000..20280ba96bf --- /dev/null +++ b/src/main/runtime/orchestration-message-waiter-registry.ts @@ -0,0 +1,235 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export const MAX_ORCHESTRATION_MESSAGE_WAITERS = 1_024 +export const MAX_ORCHESTRATION_MESSAGE_WAITERS_PER_HANDLE = 64 +export const MAX_ORCHESTRATION_MESSAGE_WAITER_HANDLE_BYTES = 64 * 1024 +export const MAX_ORCHESTRATION_MESSAGE_WAITER_RETAINED_HANDLE_BYTES = 1024 * 1024 + +const DEFAULT_MESSAGE_WAIT_TIMEOUT_MS = 2 * 60 * 1000 + +export type OrchestrationMessageWaiterBounds = { + maxWaiters: number + maxWaitersPerHandle: number + maxHandleBytes: number + maxRetainedHandleBytes: number +} + +type MessageWaiterBucket = { + handle: string + handleBytes: number + waiters: Set +} + +type MessageWaiter = { + bucket: MessageWaiterBucket + typeFilter: Set | undefined + resolve: () => void + timeout: ReturnType | null + signal: AbortSignal | undefined + onAbort: () => void + active: boolean +} + +const DEFAULT_BOUNDS: OrchestrationMessageWaiterBounds = { + maxWaiters: MAX_ORCHESTRATION_MESSAGE_WAITERS, + maxWaitersPerHandle: MAX_ORCHESTRATION_MESSAGE_WAITERS_PER_HANDLE, + maxHandleBytes: MAX_ORCHESTRATION_MESSAGE_WAITER_HANDLE_BYTES, + maxRetainedHandleBytes: MAX_ORCHESTRATION_MESSAGE_WAITER_RETAINED_HANDLE_BYTES +} + +export type OrchestrationMessageWaiterLimitReason = + | 'global' + | 'per-handle' + | 'handle-bytes' + | 'retained-handle-bytes' + +export class OrchestrationMessageWaiterLimitError extends Error { + constructor( + readonly reason: OrchestrationMessageWaiterLimitReason, + readonly limit: number + ) { + super(getLimitErrorMessage(reason, limit)) + this.name = 'OrchestrationMessageWaiterLimitError' + } +} + +export class OrchestrationMessageWaiterRegistry { + private readonly waitersByHandle = new Map() + private waiterCount = 0 + private retainedHandleBytes = 0 + private closed = false + + constructor( + private readonly bounds: OrchestrationMessageWaiterBounds = DEFAULT_BOUNDS, + private readonly defaultTimeoutMs = DEFAULT_MESSAGE_WAIT_TIMEOUT_MS + ) { + if ( + !Number.isSafeInteger(bounds.maxWaiters) || + bounds.maxWaiters < 1 || + !Number.isSafeInteger(bounds.maxWaitersPerHandle) || + bounds.maxWaitersPerHandle < 1 || + !Number.isSafeInteger(bounds.maxHandleBytes) || + bounds.maxHandleBytes < 1 || + !Number.isSafeInteger(bounds.maxRetainedHandleBytes) || + bounds.maxRetainedHandleBytes < 1 + ) { + throw new RangeError('Orchestration message waiter bounds must be positive integers') + } + } + + wait( + handle: string, + options: { typeFilter?: string[]; timeoutMs?: number; signal?: AbortSignal } = {} + ): Promise { + if (options.signal?.aborted || this.closed) { + return Promise.resolve() + } + + const bucket = this.admit(handle) + const typeFilter = options.typeFilter ? new Set(options.typeFilter) : undefined + + return new Promise((resolve) => { + let waiter!: MessageWaiter + const onAbort = (): void => this.settle(waiter) + waiter = { + bucket, + typeFilter, + resolve, + timeout: null, + signal: options.signal, + onAbort, + active: true + } + this.retain(waiter) + options.signal?.addEventListener('abort', onAbort, { once: true }) + if (options.signal?.aborted) { + this.settle(waiter) + return + } + waiter.timeout = setTimeout( + () => this.settle(waiter), + options.timeoutMs ?? this.defaultTimeoutMs + ) + }) + } + + notify(handle: string, messageType?: string): number { + const bucket = this.waitersByHandle.get(handle) + if (!bucket) { + return 0 + } + + let notified = 0 + for (const waiter of Array.from(bucket.waiters)) { + if (messageType && waiter.typeFilter && !waiter.typeFilter.has(messageType)) { + continue + } + this.settle(waiter) + notified += 1 + } + return notified + } + + close(): void { + if (this.closed) { + return + } + this.closed = true + for (const bucket of this.waitersByHandle.values()) { + for (const waiter of bucket.waiters) { + this.settle(waiter) + } + } + } + + evidence(): { waiters: number; handles: number; retainedHandleBytes: number } { + return { + waiters: this.waiterCount, + handles: this.waitersByHandle.size, + retainedHandleBytes: this.retainedHandleBytes + } + } + + private admit(handle: string): MessageWaiterBucket { + const existing = this.waitersByHandle.get(handle) + if (existing) { + if (this.waiterCount >= this.bounds.maxWaiters) { + throw new OrchestrationMessageWaiterLimitError('global', this.bounds.maxWaiters) + } + if (existing.waiters.size >= this.bounds.maxWaitersPerHandle) { + throw new OrchestrationMessageWaiterLimitError( + 'per-handle', + this.bounds.maxWaitersPerHandle + ) + } + return existing + } + + const measurement = measureUtf8ByteLength(handle, { + stopAfterBytes: this.bounds.maxHandleBytes + }) + if (measurement.exceededLimit) { + throw new OrchestrationMessageWaiterLimitError('handle-bytes', this.bounds.maxHandleBytes) + } + if (this.waiterCount >= this.bounds.maxWaiters) { + throw new OrchestrationMessageWaiterLimitError('global', this.bounds.maxWaiters) + } + if (measurement.byteLength > this.bounds.maxRetainedHandleBytes - this.retainedHandleBytes) { + throw new OrchestrationMessageWaiterLimitError( + 'retained-handle-bytes', + this.bounds.maxRetainedHandleBytes + ) + } + return { + handle, + handleBytes: measurement.byteLength, + waiters: new Set() + } + } + + private retain(waiter: MessageWaiter): void { + const { bucket } = waiter + if (bucket.waiters.size === 0) { + this.waitersByHandle.set(bucket.handle, bucket) + this.retainedHandleBytes += bucket.handleBytes + } + bucket.waiters.add(waiter) + this.waiterCount += 1 + } + + private settle(waiter: MessageWaiter): void { + if (!waiter.active) { + return + } + waiter.active = false + if (waiter.timeout !== null) { + clearTimeout(waiter.timeout) + waiter.timeout = null + } + waiter.signal?.removeEventListener('abort', waiter.onAbort) + const { bucket } = waiter + if (bucket.waiters.delete(waiter)) { + this.waiterCount -= 1 + } + if (bucket.waiters.size === 0 && this.waitersByHandle.delete(bucket.handle)) { + this.retainedHandleBytes -= bucket.handleBytes + } + waiter.resolve() + } +} + +function getLimitErrorMessage( + reason: OrchestrationMessageWaiterLimitReason, + limit: number +): string { + if (reason === 'handle-bytes') { + return `Orchestration message wait handle exceeds ${limit} UTF-8 bytes.` + } + if (reason === 'per-handle') { + return `Orchestration message wait capacity reached for this terminal (${limit}); retry shortly.` + } + if (reason === 'retained-handle-bytes') { + return `Orchestration message wait handle capacity reached (${limit} UTF-8 bytes); retry shortly.` + } + return `Orchestration message wait capacity reached (${limit}); retry shortly.` +} diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index d0e56b7e40a..7c640588387 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -3,6 +3,12 @@ import type { OrchestrationDb } from './db' import type { MessageRow, TaskRow, CoordinatorStatus } from './types' import { buildDispatchPreamble } from './preamble' import { reconcileLifecycleMessage } from './lifecycle-reconciliation' +import { + orchestrationRowRetainedUtf8Bytes, + ORCHESTRATION_QUERY_MAX_RETAINED_UTF8_BYTES, + ORCHESTRATION_QUERY_MAX_ROWS, + parseOrchestrationJson +} from './query-retention' export type CoordinatorRuntime = { sendTerminalAgentPrompt(handle: string, prompt: string): Promise @@ -66,6 +72,7 @@ type CoordinatorState = { completedTasks: string[] failedTasks: string[] escalations: MessageRow[] + escalationRetainedBytes: number } const DEFAULT_POLL_MS = 2000 @@ -74,6 +81,23 @@ const MAX_CONCURRENT_DEFAULT = 4 // Why: 10 min = documented heartbeat cadence (5 min) × 2, so one missed heartbeat is the earliest a dispatch can look stale. const HUNG_THRESHOLD_MS = 10 * 60 * 1000 +function mergeCoordinatorTaskIds(...sources: string[][]): string[] { + const retained: string[] = [] + const seen = new Set() + for (const source of sources) { + for (const id of source) { + if (!seen.has(id)) { + seen.add(id) + retained.push(id) + if (retained.length >= ORCHESTRATION_QUERY_MAX_ROWS) { + return retained + } + } + } + } + return retained +} + export class Coordinator { private db: OrchestrationDb private runtime: CoordinatorRuntime @@ -100,7 +124,8 @@ export class Coordinator { phase: 'decomposing', completedTasks: [], failedTasks: [], - escalations: [] + escalations: [], + escalationRetainedBytes: 0 } } @@ -152,14 +177,12 @@ export class Coordinator { } // Why: an early stop leaves tasks incomplete, so the run counts as failed. - const tasks = this.db.listTasks() - const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed') - const failedTasks = [ - ...new Set([ - ...this.state.failedTasks, - ...tasks.filter((task) => task.status === 'failed').map((task) => task.id) - ]) - ] + const counts = this.db.getTaskStatusCounts() + const allDone = counts.total === counts.completed + counts.failed + const failedTasks = mergeCoordinatorTaskIds( + this.state.failedTasks, + this.db.listTaskIdsByStatus('failed') + ) const finalStatus = this.stopped || failedTasks.length > 0 || !allDone ? 'failed' : 'completed' this.db.updateCoordinatorRun(runId, finalStatus) @@ -185,13 +208,13 @@ export class Coordinator { // Why: decomposition isn't implemented yet — tasks must be pre-created before run(); AI-driven decomposition is a future phase. private async decompose(): Promise { this.state.phase = 'decomposing' - const existing = this.db.listTasks() - if (existing.length === 0) { + const taskCount = this.db.getTaskStatusCounts().total + if (taskCount === 0) { throw new Error( 'No tasks found. Create tasks with orchestration.taskCreate before running the coordinator.' ) } - this.opts.onLog(`Found ${existing.length} tasks in DAG`) + this.opts.onLog(`Found ${taskCount} tasks in DAG`) this.state.phase = 'dispatching' } @@ -207,13 +230,12 @@ export class Coordinator { // Why: warn only, never auto-fail — a false positive (slow but correct worker) costs more than a false negative (hung worker holding a slot); see R6 of DESIGN_DOC_PREAMBLE_FIX.md. private warnStaleDispatches(): void { const thresholdIso = new Date(Date.now() - HUNG_THRESHOLD_MS).toISOString() - const stale = this.db.getStaleDispatches(thresholdIso) - for (const ctx of stale) { + this.db.forEachStaleDispatch(thresholdIso, (ctx) => { const minutes = Math.round(HUNG_THRESHOLD_MS / 60000) this.opts.onLog( `Warning: worker ${ctx.assignee_handle ?? ''} on task ${ctx.task_id} has not sent a heartbeat in ~${minutes} min (dispatch ${ctx.id})` ) - } + }) } private processMessages(): void { @@ -252,7 +274,10 @@ export class Coordinator { private handleLifecycleMessage(msg: MessageRow): void { const result = reconcileLifecycleMessage(this.db, msg, this.opts.onLog) if (result.action === 'completed') { - if (!this.state.completedTasks.includes(result.taskId)) { + if ( + this.state.completedTasks.length < ORCHESTRATION_QUERY_MAX_ROWS && + !this.state.completedTasks.includes(result.taskId) + ) { this.state.completedTasks.push(result.taskId) } } @@ -260,13 +285,24 @@ export class Coordinator { private handleEscalation(msg: MessageRow): void { this.opts.onLog(`Escalation from ${msg.from_handle}: ${msg.subject}`) - this.state.escalations.push(msg) + const messageBytes = orchestrationRowRetainedUtf8Bytes(msg) + if ( + this.state.escalations.length < ORCHESTRATION_QUERY_MAX_ROWS && + this.state.escalationRetainedBytes + messageBytes <= + ORCHESTRATION_QUERY_MAX_RETAINED_UTF8_BYTES + ) { + this.state.escalations.push(msg) + this.state.escalationRetainedBytes += messageBytes + } let taskId: string | undefined if (msg.payload) { try { - const payload = JSON.parse(msg.payload) - taskId = payload.taskId + const payload = parseOrchestrationJson(msg.payload) + if (payload && typeof payload === 'object') { + const rawTaskId = (payload as { taskId?: unknown }).taskId + taskId = typeof rawTaskId === 'string' ? rawTaskId : undefined + } } catch { // Escalation without structured payload — log subject as context } @@ -291,7 +327,12 @@ export class Coordinator { if (updated?.status === 'circuit_broken') { this.opts.onLog(`Task ${taskId} circuit broken after repeated failures`) this.db.updateTaskStatus(taskId, 'failed', `Circuit broken: ${msg.subject}`) - this.state.failedTasks.push(taskId) + if ( + this.state.failedTasks.length < ORCHESTRATION_QUERY_MAX_ROWS && + !this.state.failedTasks.includes(taskId) + ) { + this.state.failedTasks.push(taskId) + } } else { this.opts.onLog(`Task ${taskId} will be retried (failure ${updated?.failure_count ?? 0}/3)`) } @@ -303,7 +344,10 @@ export class Coordinator { let payload: { taskId?: string; question?: string; options?: string[] } = {} if (msg.payload) { try { - payload = JSON.parse(msg.payload) + const parsed = parseOrchestrationJson(msg.payload) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + payload = parsed as typeof payload + } } catch { return } @@ -329,14 +373,7 @@ export class Coordinator { private processDecisionGates(): void { // Why: the coordinator never auto-resolves gates (humans do, via orchestration.gateResolve) — that would defeat them as approval checkpoints. - const pendingGates = this.db.listGates({ status: 'pending' }) - for (const gate of pendingGates) { - const task = this.db.getTask(gate.task_id) - if (task && task.status !== 'blocked') { - // Why: gate exists but task isn't blocked — re-block to restore the invariant. - this.db.updateTaskStatus(gate.task_id, 'blocked') - } - } + this.db.restorePendingGateTaskStatuses() } private async dispatchReadyTasks(): Promise { @@ -346,8 +383,8 @@ export class Coordinator { return } - const dispatched = this.db.listTasks({ status: 'dispatched' }) - let slotsAvailable = this.opts.maxConcurrent - dispatched.length + const dispatchedCount = this.db.getTaskStatusCounts().dispatched + let slotsAvailable = this.opts.maxConcurrent - dispatchedCount if (slotsAvailable <= 0) { return } @@ -437,10 +474,9 @@ export class Coordinator { }) // Why: surface a since-resolved decision gate's outcome to the worker via the preamble. - const gates = this.db.listGates({ taskId: task.id, status: 'resolved' }) let gateContext = '' - if (gates.length > 0) { - const latest = gates.at(-1)! + const latest = this.db.getLatestGate({ taskId: task.id, status: 'resolved' }) + if (latest) { gateContext = `\n\n--- DECISION GATE RESOLVED ---\nQuestion: ${latest.question}\nResolution: ${latest.resolution}\n---\n` } @@ -452,7 +488,12 @@ export class Coordinator { err instanceof Error ? err.message : String(err) ) if (updated?.status === 'circuit_broken') { - this.state.failedTasks.push(task.id) + if ( + this.state.failedTasks.length < ORCHESTRATION_QUERY_MAX_ROWS && + !this.state.failedTasks.includes(task.id) + ) { + this.state.failedTasks.push(task.id) + } } throw err } @@ -464,13 +505,11 @@ export class Coordinator { private async getAvailableTerminals(): Promise { try { const result = await this.runtime.listTerminals(this.opts.worktree) - const dispatched = this.db.listTasks({ status: 'dispatched' }) const busyHandles = new Set() - for (const task of dispatched) { - const ctx = this.db.getDispatchContext(task.id) - if (ctx?.assignee_handle) { - busyHandles.add(ctx.assignee_handle) + for (const terminal of result.terminals) { + if (this.db.getActiveDispatchForTerminal(terminal.handle)) { + busyHandles.add(terminal.handle) } } @@ -490,25 +529,22 @@ export class Coordinator { } private checkConvergence(): boolean { - const tasks = this.db.listTasks() - if (tasks.length === 0) { + const counts = this.db.getTaskStatusCounts() + if (counts.total === 0) { return true } - const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed') + const allDone = counts.total === counts.completed + counts.failed if (allDone) { this.state.phase = 'done' return true } // Why: no active tasks but some blocked → dependencies can never be satisfied (stuck). - const active = tasks.filter( - (t) => t.status === 'ready' || t.status === 'dispatched' || t.status === 'pending' - ) - const blocked = tasks.filter((t) => t.status === 'blocked') - if (active.length === 0 && blocked.length > 0) { + const activeCount = counts.ready + counts.dispatched + counts.pending + if (activeCount === 0 && counts.blocked > 0) { this.opts.onLog( - `Stuck: ${blocked.length} tasks blocked with no active tasks. Resolve decision gates to continue.` + `Stuck: ${counts.blocked} tasks blocked with no active tasks. Resolve decision gates to continue.` ) } diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index b287cb0182c..b8cb36bd5c8 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -16,6 +16,80 @@ import type { } from './types' import { buildOrchestrationTaskDisplayMetadata } from '../../../shared/orchestration-task-display' import { parsePaneKey } from '../../../shared/stable-pane-id' +import { + assertOrchestrationStringListFits, + assertOrchestrationWriteFits, + clampOrchestrationQueryLimit, + ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES, + ORCHESTRATION_QUERY_MAX_ROWS, + ORCHESTRATION_WRITE_MAX_ITEMS, + parseOrchestrationJson, + retainOrchestrationQueryRows, + truncateOrchestrationDiagnostic +} from './query-retention' + +function retainedTextBytesSql(columns: string[]): string { + return columns.map((column) => `length(CAST(COALESCE(${column}, '') AS BLOB))`).join(' + ') +} + +const MESSAGE_ROW_BYTES_SQL = retainedTextBytesSql([ + 'messages.id', + 'messages.from_handle', + 'messages.to_handle', + 'messages.subject', + 'messages.body', + 'messages.type', + 'messages.priority', + 'messages.thread_id', + 'messages.payload', + 'messages.created_at', + 'messages.delivered_at', + 'messages.sender_pane_key' +]) +const TASK_ROW_BYTES_SQL = retainedTextBytesSql([ + 'tasks.id', + 'tasks.parent_id', + 'tasks.created_by_terminal_handle', + 'tasks.task_title', + 'tasks.display_name', + 'tasks.spec', + 'tasks.status', + 'tasks.deps', + 'tasks.result', + 'tasks.created_at', + 'tasks.completed_at' +]) +const DISPATCH_ROW_BYTES_SQL = retainedTextBytesSql([ + 'dispatch_contexts.id', + 'dispatch_contexts.task_id', + 'dispatch_contexts.assignee_handle', + 'dispatch_contexts.assignee_pane_key', + 'dispatch_contexts.status', + 'dispatch_contexts.last_failure', + 'dispatch_contexts.dispatched_at', + 'dispatch_contexts.completed_at', + 'dispatch_contexts.created_at', + 'dispatch_contexts.last_heartbeat_at' +]) +const GATE_ROW_BYTES_SQL = retainedTextBytesSql([ + 'decision_gates.id', + 'decision_gates.task_id', + 'decision_gates.question', + 'decision_gates.options', + 'decision_gates.status', + 'decision_gates.resolution', + 'decision_gates.created_at', + 'decision_gates.resolved_at' +]) +const COORDINATOR_RUN_ROW_BYTES_SQL = retainedTextBytesSql([ + 'coordinator_runs.id', + 'coordinator_runs.spec', + 'coordinator_runs.status', + 'coordinator_runs.coordinator_handle', + 'coordinator_runs.created_at', + 'coordinator_runs.completed_at' +]) +const INTERNAL_SCAN_BATCH_ROWS = 64 // Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys. function isEquivalentPaneKey(a: string, b: string): boolean { @@ -41,14 +115,29 @@ export type { CoordinatorRun } +export type TaskStatusCounts = Record & { total: number } + function generateId(prefix: string): string { return `${prefix}_${randomBytes(6).toString('hex')}` } +function uniqueMessageTypes(types: MessageType[] | undefined): MessageType[] { + const unique: MessageType[] = [] + for (const type of types ?? []) { + if (!unique.includes(type)) { + unique.push(type) + if (unique.length === 8) { + break + } + } + } + return unique +} + function addLifecycleRejectionMarker(payload: string | null, reason: string): string { let parsed: Record = {} try { - const value: unknown = payload ? JSON.parse(payload) : {} + const value: unknown = payload ? parseOrchestrationJson(payload) : {} if (value && typeof value === 'object' && !Array.isArray(value)) { parsed = value as Record } @@ -106,6 +195,24 @@ export class OrchestrationDb { this.migrate() } + private readRows( + sql: string, + params: Database.BindValue[] = [], + requestedLimit?: number + ): T[] { + const limit = clampOrchestrationQueryLimit(requestedLimit) + if (limit === 0) { + return [] + } + const rows = this.db.prepare(`${sql} LIMIT ?`).iterate(...params, limit) as Iterable + return retainOrchestrationQueryRows(rows, limit) + } + + private countRows(sql: string, params: Database.BindValue[] = []): number { + const row = this.db.prepare(sql).get(...params) as { count: number } + return row.count + } + private createTables(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS messages ( @@ -333,6 +440,17 @@ export class OrchestrationDb { payload?: string senderPaneKey?: string }): MessageRow { + assertOrchestrationWriteFits('Message', [ + msg.from, + msg.to, + msg.subject, + msg.body, + msg.type, + msg.priority, + msg.threadId, + msg.payload, + msg.senderPaneKey + ]) const id = generateId('msg') const stmt = this.db.prepare(` INSERT INTO messages (id, from_handle, to_handle, subject, body, type, priority, thread_id, payload, sender_pane_key) @@ -350,26 +468,37 @@ export class OrchestrationDb { msg.payload ?? null, msg.senderPaneKey ?? null ) - return exposeMessageTimestamps( - this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow - ) + return this.getMessageById(id)! } getUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] { - if (types && types.length > 0) { - const placeholders = types.map(() => '?').join(',') - return exposeMessageListTimestamps( - this.db - .prepare( - `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND type IN (${placeholders}) ORDER BY sequence` - ) - .all(toHandle, ...types) as MessageRow[] - ) + const params: Database.BindValue[] = [toHandle] + let typeFilter = '' + const retainedTypes = uniqueMessageTypes(types) + if (retainedTypes.length > 0) { + const placeholders = retainedTypes.map(() => '?').join(',') + typeFilter = ` AND type IN (${placeholders})` + params.push(...retainedTypes) } return exposeMessageListTimestamps( - this.db - .prepare('SELECT * FROM messages WHERE to_handle = ? AND read = 0 ORDER BY sequence') - .all(toHandle) as MessageRow[] + this.readRows( + `SELECT * FROM messages + WHERE to_handle = ? AND read = 0${typeFilter} + AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY sequence`, + params + ) + ) + } + + countUnreadMessages(toHandle: string, types?: MessageType[]): number { + const retainedTypes = uniqueMessageTypes(types) + const placeholders = retainedTypes.map(() => '?').join(',') + const typeFilter = retainedTypes.length > 0 ? ` AND type IN (${placeholders})` : '' + return this.countRows( + `SELECT COUNT(*) AS count FROM messages + WHERE to_handle = ? AND read = 0${typeFilter}`, + [toHandle, ...retainedTypes] ) } @@ -380,8 +509,11 @@ export class OrchestrationDb { } const originalBody = message.body ? `\n\nOriginal body:\n${message.body}` : '' - const body = `Orca rejected this ${message.type}: ${reason}${originalBody}` - const payload = addLifecycleRejectionMarker(message.payload, reason) + const boundedReason = truncateOrchestrationDiagnostic(reason) + const body = truncateOrchestrationDiagnostic( + `Orca rejected this ${message.type}: ${boundedReason}${originalBody}` + ) + const payload = addLifecycleRejectionMarker(message.payload, boundedReason) // Why: rejected lifecycle signals stay auditable but must not reach read paths as actionable completion/liveness events. this.db .prepare( @@ -395,116 +527,133 @@ export class OrchestrationDb { // Why: delivered_at IS NULL filter — push-on-idle delivers each row at most once; read (set only by check) wouldn't prevent replay. getUndeliveredUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] { - if (types && types.length > 0) { - const placeholders = types.map(() => '?').join(',') - return exposeMessageListTimestamps( - this.db - .prepare( - `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL AND type IN (${placeholders}) ORDER BY sequence` - ) - .all(toHandle, ...types) as MessageRow[] - ) + const params: Database.BindValue[] = [toHandle] + let typeFilter = '' + const retainedTypes = uniqueMessageTypes(types) + if (retainedTypes.length > 0) { + const placeholders = retainedTypes.map(() => '?').join(',') + typeFilter = ` AND type IN (${placeholders})` + params.push(...retainedTypes) } return exposeMessageListTimestamps( - this.db - .prepare( - 'SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL ORDER BY sequence' - ) - .all(toHandle) as MessageRow[] + this.readRows( + `SELECT * FROM messages + WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL${typeFilter} + AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY sequence`, + params + ) ) } getAllMessages(toHandle: string, limit = 20): MessageRow[] { return exposeMessageListTimestamps( - this.db - .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') - .all(toHandle, limit) as MessageRow[] + this.readRows( + `SELECT * FROM messages + WHERE to_handle = ? + AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY sequence DESC`, + [toHandle], + limit + ) ) } getMessageById(id: string): MessageRow | undefined { - const message = this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as - | MessageRow - | undefined + const message = this.db + .prepare( + `SELECT * FROM messages + WHERE id = ? AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` + ) + .get(id) as MessageRow | undefined return message ? exposeMessageTimestamps(message) : undefined } markAsRead(ids: string[]): void { - if (ids.length === 0) { - return - } - const placeholders = ids.map(() => '?').join(',') - this.db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${placeholders})`).run(...ids) + this.updateMessageIds(ids, 'read = 1') } // Why: use datetime('now') so delivered_at matches the space-format UTC shape of the table's other timestamps for correct ordering (§3.2). markAsDelivered(ids: string[]): void { - if (ids.length === 0) { - return - } - const placeholders = ids.map(() => '?').join(',') - this.db - .prepare(`UPDATE messages SET delivered_at = datetime('now') WHERE id IN (${placeholders})`) - .run(...ids) + this.updateMessageIds(ids, "delivered_at = datetime('now')") } markAsReadAndDelivered(ids: string[]): void { - if (ids.length === 0) { - return - } - const placeholders = ids.map(() => '?').join(',') // Why: superseded lifecycle messages stay in history but must not be consumed or injected after their dispatch finished. - this.db - .prepare( - `UPDATE messages SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now')) WHERE id IN (${placeholders})` - ) - .run(...ids) + this.updateMessageIds(ids, "read = 1, delivered_at = COALESCE(delivered_at, datetime('now'))") + } + + private updateMessageIds(ids: string[], assignments: string): void { + for (let start = 0; start < ids.length; start += ORCHESTRATION_QUERY_MAX_ROWS) { + const batch = ids.slice(start, start + ORCHESTRATION_QUERY_MAX_ROWS) + const placeholders = batch.map(() => '?').join(',') + this.db + .prepare(`UPDATE messages SET ${assignments} WHERE id IN (${placeholders})`) + .run(...batch) + } } getInbox(limit = 20): MessageRow[] { return exposeMessageListTimestamps( - this.db - .prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?') - .all(limit) as MessageRow[] + this.readRows( + `SELECT * FROM messages + WHERE (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY sequence DESC`, + [], + limit + ) ) } + countInbox(): number { + return this.countRows('SELECT COUNT(*) AS count FROM messages') + } + // Why: read-only history for a handle — returns every message regardless of read/delivered state, never flips the read bit (§3.3). getAllMessagesForHandle(toHandle: string, limit = 100, types?: MessageType[]): MessageRow[] { - if (types && types.length > 0) { - const placeholders = types.map(() => '?').join(',') - return exposeMessageListTimestamps( - this.db - .prepare( - `SELECT * FROM messages WHERE to_handle = ? AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?` - ) - .all(toHandle, ...types, limit) as MessageRow[] - ) + const params: Database.BindValue[] = [toHandle] + let typeFilter = '' + const retainedTypes = uniqueMessageTypes(types) + if (retainedTypes.length > 0) { + const placeholders = retainedTypes.map(() => '?').join(',') + typeFilter = ` AND type IN (${placeholders})` + params.push(...retainedTypes) } return exposeMessageListTimestamps( - this.db - .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') - .all(toHandle, limit) as MessageRow[] + this.readRows( + `SELECT * FROM messages + WHERE to_handle = ?${typeFilter} + AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY sequence DESC`, + params, + limit + ) + ) + } + + countAllMessagesForHandle(toHandle: string, types?: MessageType[]): number { + const retainedTypes = uniqueMessageTypes(types) + const placeholders = retainedTypes.map(() => '?').join(',') + const typeFilter = retainedTypes.length > 0 ? ` AND type IN (${placeholders})` : '' + return this.countRows( + `SELECT COUNT(*) AS count FROM messages WHERE to_handle = ?${typeFilter}`, + [toHandle, ...retainedTypes] ) } // Why: ask wait-loop read — to_handle filter shows only replies to the worker; afterSequence resumes past its own outbound ask. getThreadMessagesFor(threadId: string, toHandle: string, afterSequence?: number): MessageRow[] { - if (afterSequence !== undefined) { - return exposeMessageListTimestamps( - this.db - .prepare( - 'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? AND sequence > ? ORDER BY sequence ASC' - ) - .all(threadId, toHandle, afterSequence) as MessageRow[] - ) - } + const sequenceFilter = afterSequence === undefined ? '' : ' AND sequence > ?' + const params: Database.BindValue[] = + afterSequence === undefined ? [threadId, toHandle] : [threadId, toHandle, afterSequence] return exposeMessageListTimestamps( - this.db - .prepare( - 'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? ORDER BY sequence ASC' - ) - .all(threadId, toHandle) as MessageRow[] + this.readRows( + `SELECT * FROM messages + WHERE thread_id = ? AND to_handle = ?${sequenceFilter} + AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY sequence ASC`, + params + ) ) } @@ -519,14 +668,32 @@ export class OrchestrationDb { createdByTerminalHandle?: string }): TaskRow { const id = generateId('task') - const depsJson = JSON.stringify(task.deps ?? []) - const hasDeps = (task.deps ?? []).length > 0 + const deps = task.deps ?? [] + assertOrchestrationStringListFits('Task dependencies', deps) + assertOrchestrationWriteFits('Task', [ + task.spec, + task.taskTitle, + task.displayName, + task.parentId, + task.createdByTerminalHandle, + ...deps + ]) + const depsJson = JSON.stringify(deps) + const hasDeps = deps.length > 0 const status: TaskStatus = hasDeps ? 'pending' : 'ready' const display = buildOrchestrationTaskDisplayMetadata({ spec: task.spec, taskTitle: task.taskTitle, displayName: task.displayName }) + assertOrchestrationWriteFits('Task', [ + task.spec, + display.taskTitle, + display.displayName, + depsJson, + task.parentId, + task.createdByTerminalHandle + ]) this.db .prepare( 'INSERT INTO tasks (id, parent_id, created_by_terminal_handle, task_title, display_name, spec, status, deps) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' @@ -541,25 +708,35 @@ export class OrchestrationDb { status, depsJson ) - return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow + return this.getTask(id)! } getTask(id: string): TaskRow | undefined { - return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined + return this.db + .prepare( + `SELECT * FROM tasks + WHERE id = ? AND (${TASK_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` + ) + .get(id) as TaskRow | undefined } listTasks(filter?: { status?: TaskStatus; ready?: boolean }): TaskRow[] { - if (filter?.ready) { - return this.db - .prepare("SELECT * FROM tasks WHERE status = 'ready' ORDER BY created_at") - .all() as TaskRow[] - } - if (filter?.status) { - return this.db - .prepare('SELECT * FROM tasks WHERE status = ? ORDER BY created_at') - .all(filter.status) as TaskRow[] - } - return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[] + const status = filter?.ready ? 'ready' : filter?.status + const statusFilter = status ? 'status = ? AND ' : '' + return this.readRows( + `SELECT * FROM tasks + WHERE ${statusFilter}(${TASK_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY created_at, rowid`, + status ? [status] : [] + ) + } + + countTasks(filter?: { status?: TaskStatus; ready?: boolean }): number { + const status = filter?.ready ? 'ready' : filter?.status + return this.countRows( + `SELECT COUNT(*) AS count FROM tasks${status ? ' WHERE status = ?' : ''}`, + status ? [status] : [] + ) } // Why: LEFT JOIN keeps non-dispatched tasks (NULL assignee); the MAX(rowid) subquery matches getDispatchContext's most-recent-active-dispatch semantics. @@ -570,20 +747,25 @@ export class OrchestrationDb { const whereClauses: string[] = [] const params: Database.BindValue[] = [] if (filter?.ready) { - whereClauses.push("t.status = 'ready'") + whereClauses.push("tasks.status = 'ready'") } else if (filter?.status) { - whereClauses.push('t.status = ?') + whereClauses.push('tasks.status = ?') params.push(filter.status) } + whereClauses.push( + `(${TASK_ROW_BYTES_SQL} + + length(CAST(COALESCE(d.assignee_handle, '') AS BLOB)) + + length(CAST(COALESCE(d.id, '') AS BLOB))) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` + ) const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' const sql = ` SELECT - t.*, + tasks.*, d.assignee_handle AS assignee_handle, d.id AS dispatch_id - FROM tasks t + FROM tasks LEFT JOIN ( - SELECT dc.* + SELECT dc.id, dc.task_id, dc.assignee_handle FROM dispatch_contexts dc INNER JOIN ( SELECT task_id, MAX(rowid) AS max_rowid @@ -591,17 +773,23 @@ export class OrchestrationDb { WHERE status IN ('pending', 'dispatched') GROUP BY task_id ) latest ON latest.task_id = dc.task_id AND latest.max_rowid = dc.rowid - ) d ON d.task_id = t.id + ) d ON d.task_id = tasks.id ${where} - ORDER BY t.created_at + ORDER BY tasks.created_at, tasks.rowid ` - return this.db.prepare(sql).all(...params) as (TaskRow & { + return this.readRows< + TaskRow & { + assignee_handle: string | null + dispatch_id: string | null + } + >(sql, params) as (TaskRow & { assignee_handle: string | null dispatch_id: string | null })[] } updateTaskStatus(id: string, status: TaskStatus, result?: string): TaskRow | undefined { + assertOrchestrationWriteFits('Task result', [result]) const completedAt = status === 'completed' || status === 'failed' ? new Date().toISOString() : null this.db @@ -620,26 +808,81 @@ export class OrchestrationDb { // Why: runs in the status-update transaction, so a completed task never leaves its ready children unpromoted. private promoteReadyTasks(completedTaskId: string): void { - const candidates = this.db - .prepare("SELECT * FROM tasks WHERE status = 'pending'") - .all() as TaskRow[] - - for (const task of candidates) { - const deps: string[] = JSON.parse(task.deps) - if (!deps.includes(completedTaskId)) { - continue + let afterRowId = 0 + while (true) { + const candidates = this.readRows<{ rowid: number; id: string; deps: string }>( + `SELECT rowid, id, deps FROM tasks + WHERE status = 'pending' AND rowid > ? + AND (${retainedTextBytesSql(['tasks.id', 'tasks.deps'])}) + <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + AND CASE WHEN json_valid(deps) + THEN json_type(deps) = 'array' + AND json_array_length(deps) <= ${ORCHESTRATION_WRITE_MAX_ITEMS} + ELSE 0 + END + ORDER BY rowid`, + [afterRowId], + INTERNAL_SCAN_BATCH_ROWS + ) + if (candidates.length === 0) { + return } + afterRowId = candidates.at(-1)!.rowid - const allDepsCompleted = deps.every((depId) => { - const dep = this.getTask(depId) - return dep?.status === 'completed' - }) - if (allDepsCompleted) { - this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + for (const task of candidates) { + let parsedDeps: unknown + try { + parsedDeps = parseOrchestrationJson(task.deps) + } catch { + continue + } + if ( + !Array.isArray(parsedDeps) || + parsedDeps.length > ORCHESTRATION_WRITE_MAX_ITEMS || + !parsedDeps.every((dependency) => typeof dependency === 'string') + ) { + continue + } + const deps = parsedDeps + if (!deps.includes(completedTaskId)) { + continue + } + const allDepsCompleted = deps.every((depId) => this.getTask(depId)?.status === 'completed') + if (allDepsCompleted) { + this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + } } } } + getTaskStatusCounts(): TaskStatusCounts { + const row = this.db + .prepare( + `SELECT + COUNT(*) AS total, + COALESCE(SUM(status = 'pending'), 0) AS pending, + COALESCE(SUM(status = 'ready'), 0) AS ready, + COALESCE(SUM(status = 'dispatched'), 0) AS dispatched, + COALESCE(SUM(status = 'completed'), 0) AS completed, + COALESCE(SUM(status = 'failed'), 0) AS failed, + COALESCE(SUM(status = 'blocked'), 0) AS blocked + FROM tasks` + ) + .get() as TaskStatusCounts + return row + } + + listTaskIdsByStatus(status: TaskStatus): string[] { + const rows = this.readRows<{ id: string }>( + `SELECT id FROM tasks + WHERE status = ? + AND length(CAST(id AS BLOB)) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY created_at, rowid`, + [status] + ) + return rows.map((row) => row.id) + } + // ── Dispatch Contexts ── createDispatchContext( @@ -648,6 +891,7 @@ export class OrchestrationDb { // Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue. assigneePaneKey?: string ): DispatchContextRow { + assertOrchestrationWriteFits('Dispatch context', [taskId, assigneeHandle, assigneePaneKey]) const task = this.getTask(taskId) if (!task) { throw new Error(`Task not found: ${taskId}`) @@ -682,21 +926,28 @@ export class OrchestrationDb { this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId) - return this.db - .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') - .get(id) as DispatchContextRow + return this.getDispatchContextById(id)! } getDispatchContext(taskId: string): DispatchContextRow | undefined { return this.db - .prepare('SELECT * FROM dispatch_contexts WHERE task_id = ? ORDER BY rowid DESC LIMIT 1') + .prepare( + `SELECT * FROM dispatch_contexts + WHERE task_id = ? + AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY rowid DESC LIMIT 1` + ) .get(taskId) as DispatchContextRow | undefined } getDispatchContextById(dispatchId: string): DispatchContextRow | undefined { - return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(dispatchId) as - | DispatchContextRow - | undefined + return this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE id = ? + AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` + ) + .get(dispatchId) as DispatchContextRow | undefined } getActiveDispatchForTerminal(handle: string): DispatchContextRow | undefined { @@ -723,7 +974,10 @@ export class OrchestrationDb { ): DispatchContextRow | undefined { const byHandle = this.db .prepare( - "SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1" + `SELECT * FROM dispatch_contexts + WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') + AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + LIMIT 1` ) .get(assigneeHandle) as DispatchContextRow | undefined if (byHandle) { @@ -736,22 +990,33 @@ export class OrchestrationDb { const actives = this.db .prepare( - "SELECT * FROM dispatch_contexts WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched')" + `SELECT id, assignee_pane_key FROM dispatch_contexts + WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched') + AND (${retainedTextBytesSql([ + 'dispatch_contexts.id', + 'dispatch_contexts.assignee_pane_key' + ])}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY rowid` ) - .all() as DispatchContextRow[] + .iterate() as Iterable<{ id: string; assignee_pane_key: string }> + let matchingId: string | undefined for (const row of actives) { - if (row.assignee_pane_key && isEquivalentPaneKey(row.assignee_pane_key, assigneePaneKey)) { - return row + if (isEquivalentPaneKey(row.assignee_pane_key, assigneePaneKey)) { + matchingId = row.id + break } } - return undefined + return matchingId ? this.getDispatchContextById(matchingId) : undefined } getLatestDispatchForTerminal(handle: string): DispatchContextRow | undefined { return this.db .prepare( - 'SELECT * FROM dispatch_contexts WHERE assignee_handle = ? ORDER BY rowid DESC LIMIT 1' + `SELECT * FROM dispatch_contexts + WHERE assignee_handle = ? + AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY rowid DESC LIMIT 1` ) .get(handle) as DispatchContextRow | undefined } @@ -767,9 +1032,9 @@ export class OrchestrationDb { completeActiveDispatchForTask(taskId: string): void { const active = this.db .prepare( - "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" + "SELECT id FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" ) - .get(taskId) as DispatchContextRow | undefined + .get(taskId) as { id: string } | undefined if (active) { this.completeDispatch(active.id) } @@ -778,14 +1043,15 @@ export class OrchestrationDb { failActiveDispatchForTask(taskId: string, error: string): DispatchContextRow | undefined { const active = this.db .prepare( - "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" + "SELECT id FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" ) - .get(taskId) as DispatchContextRow | undefined + .get(taskId) as { id: string } | undefined return active ? this.failDispatch(active.id, error) : undefined } // Why: only bump status='dispatched' — a zombie heartbeat from a finished dispatch would mask a hung retry from the stale detector (§5.3.4). recordHeartbeat(dispatchId: string, at: string): void { + assertOrchestrationWriteFits('Dispatch heartbeat', [dispatchId, at]) this.db .prepare( "UPDATE dispatch_contexts SET last_heartbeat_at = ? WHERE id = ? AND status = 'dispatched'" @@ -795,21 +1061,37 @@ export class OrchestrationDb { // Why: dispatched_at grace skips workers still within their first heartbeat interval; julianday() vs raw-TEXT compare avoids misflagging space-format timestamps as stale (#8452). getStaleDispatches(thresholdIso: string): DispatchContextRow[] { - return this.db + return this.readRows( + `SELECT * FROM dispatch_contexts + WHERE status = 'dispatched' + AND dispatched_at IS NOT NULL + AND julianday(dispatched_at) < julianday(?) + AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?)) + AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY rowid`, + [thresholdIso, thresholdIso] + ) + } + + forEachStaleDispatch(thresholdIso: string, visit: (row: DispatchContextRow) => void): void { + const rows = this.db .prepare( `SELECT * FROM dispatch_contexts WHERE status = 'dispatched' AND dispatched_at IS NOT NULL AND julianday(dispatched_at) < julianday(?) - AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))` + AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?)) + AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY rowid` ) - .all(thresholdIso, thresholdIso) as DispatchContextRow[] + .iterate(thresholdIso, thresholdIso) as Iterable + for (const row of rows) { + visit(row) + } } failDispatch(ctxId: string, error: string): DispatchContextRow | undefined { - const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as - | DispatchContextRow - | undefined + const ctx = this.getDispatchContextById(ctxId) if (!ctx) { return undefined } @@ -821,22 +1103,24 @@ export class OrchestrationDb { .prepare( 'UPDATE dispatch_contexts SET status = ?, failure_count = ?, last_failure = ? WHERE id = ?' ) - .run(newStatus, newFailureCount, error, ctxId) + .run(newStatus, newFailureCount, truncateOrchestrationDiagnostic(error), ctxId) // Why: back to 'ready' not 'pending' — 'pending' would strand it since promoteReadyTasks only runs when a dep completes. const taskStatus: TaskStatus = newStatus === 'circuit_broken' ? 'failed' : 'ready' this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(taskStatus, ctx.task_id) - return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as - | DispatchContextRow - | undefined + return this.getDispatchContextById(ctxId) } // ── Decision Gates ── createGate(gate: { taskId: string; question: string; options?: string[] }): DecisionGateRow { const id = generateId('gate') - const optionsJson = JSON.stringify(gate.options ?? []) + const options = gate.options ?? [] + assertOrchestrationStringListFits('Decision gate options', options) + assertOrchestrationWriteFits('Decision gate', [gate.taskId, gate.question, ...options]) + const optionsJson = JSON.stringify(options) + assertOrchestrationWriteFits('Decision gate', [gate.taskId, gate.question, optionsJson]) this.db .prepare('INSERT INTO decision_gates (id, task_id, question, options) VALUES (?, ?, ?, ?)') .run(id, gate.taskId, gate.question, optionsJson) @@ -844,13 +1128,12 @@ export class OrchestrationDb { this.completeActiveDispatchForTask(gate.taskId) this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(gate.taskId) - return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as DecisionGateRow + return this.getGate(id)! } resolveGate(gateId: string, resolution: string): DecisionGateRow | undefined { - const gate = this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as - | DecisionGateRow - | undefined + assertOrchestrationWriteFits('Decision gate resolution', [gateId, resolution]) + const gate = this.getGate(gateId) if (!gate) { return undefined } @@ -864,9 +1147,7 @@ export class OrchestrationDb { // Why: set to 'ready' (not the previous status) so the coordinator re-dispatches the worker with the resolution context. this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(gate.task_id) - return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as - | DecisionGateRow - | undefined + return this.getGate(gateId) } timeoutGate(gateId: string): DecisionGateRow | undefined { @@ -875,38 +1156,81 @@ export class OrchestrationDb { "UPDATE decision_gates SET status = 'timeout', resolved_at = datetime('now') WHERE id = ?" ) .run(gateId) - return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as - | DecisionGateRow - | undefined + return this.getGate(gateId) } listGates(filter?: { taskId?: string; status?: GateStatus }): DecisionGateRow[] { + const clauses = [`(${GATE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}`] + const params: Database.BindValue[] = [] if (filter?.taskId && filter?.status) { - return this.db - .prepare( - 'SELECT * FROM decision_gates WHERE task_id = ? AND status = ? ORDER BY created_at' - ) - .all(filter.taskId, filter.status) as DecisionGateRow[] + clauses.push('task_id = ?', 'status = ?') + params.push(filter.taskId, filter.status) + } else if (filter?.taskId) { + clauses.push('task_id = ?') + params.push(filter.taskId) + } else if (filter?.status) { + clauses.push('status = ?') + params.push(filter.status) } + return this.readRows( + `SELECT * FROM decision_gates + WHERE ${clauses.join(' AND ')} + ORDER BY created_at, rowid`, + params + ) + } + + countGates(filter?: { taskId?: string; status?: GateStatus }): number { + const clauses: string[] = [] + const params: Database.BindValue[] = [] if (filter?.taskId) { - return this.db - .prepare('SELECT * FROM decision_gates WHERE task_id = ? ORDER BY created_at') - .all(filter.taskId) as DecisionGateRow[] + clauses.push('task_id = ?') + params.push(filter.taskId) } if (filter?.status) { - return this.db - .prepare('SELECT * FROM decision_gates WHERE status = ? ORDER BY created_at') - .all(filter.status) as DecisionGateRow[] + clauses.push('status = ?') + params.push(filter.status) } - return this.db - .prepare('SELECT * FROM decision_gates ORDER BY created_at') - .all() as DecisionGateRow[] + return this.countRows( + `SELECT COUNT(*) AS count FROM decision_gates${ + clauses.length > 0 ? ` WHERE ${clauses.join(' AND ')}` : '' + }`, + params + ) } getGate(id: string): DecisionGateRow | undefined { - return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as - | DecisionGateRow - | undefined + return this.db + .prepare( + `SELECT * FROM decision_gates + WHERE id = ? AND (${GATE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` + ) + .get(id) as DecisionGateRow | undefined + } + + getLatestGate(filter: { taskId: string; status: GateStatus }): DecisionGateRow | undefined { + return this.db + .prepare( + `SELECT * FROM decision_gates + WHERE task_id = ? AND status = ? + AND (${GATE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY created_at DESC, rowid DESC LIMIT 1` + ) + .get(filter.taskId, filter.status) as DecisionGateRow | undefined + } + + restorePendingGateTaskStatuses(): void { + this.db + .prepare( + `UPDATE tasks SET status = 'blocked' + WHERE status <> 'blocked' + AND EXISTS ( + SELECT 1 FROM decision_gates + WHERE decision_gates.task_id = tasks.id + AND decision_gates.status = 'pending' + )` + ) + .run() } // ── Coordinator Runs ── @@ -916,19 +1240,24 @@ export class OrchestrationDb { coordinatorHandle: string pollIntervalMs?: number }): CoordinatorRun { + assertOrchestrationWriteFits('Coordinator run', [run.spec, run.coordinatorHandle]) const id = generateId('run') this.db .prepare( "INSERT INTO coordinator_runs (id, spec, status, coordinator_handle, poll_interval_ms) VALUES (?, ?, 'running', ?, ?)" ) .run(id, run.spec, run.coordinatorHandle, run.pollIntervalMs ?? 2000) - return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as CoordinatorRun + return this.getCoordinatorRun(id)! } getCoordinatorRun(id: string): CoordinatorRun | undefined { - return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as - | CoordinatorRun - | undefined + return this.db + .prepare( + `SELECT * FROM coordinator_runs + WHERE id = ? + AND (${COORDINATOR_RUN_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` + ) + .get(id) as CoordinatorRun | undefined } updateCoordinatorRun(id: string, status: CoordinatorStatus): CoordinatorRun | undefined { @@ -945,7 +1274,10 @@ export class OrchestrationDb { getActiveCoordinatorRun(): CoordinatorRun | undefined { return this.db .prepare( - "SELECT * FROM coordinator_runs WHERE status = 'running' ORDER BY created_at DESC LIMIT 1" + `SELECT * FROM coordinator_runs + WHERE status = 'running' + AND (${COORDINATOR_RUN_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + ORDER BY created_at DESC, rowid DESC LIMIT 1` ) .get() as CoordinatorRun | undefined } @@ -953,22 +1285,31 @@ export class OrchestrationDb { // ── Queries for Coordinator ── getIdleTerminals(excludeHandles: string[] = []): string[] { - const active = this.db + const rows = this.db .prepare( - "SELECT DISTINCT assignee_handle FROM dispatch_contexts WHERE status IN ('pending', 'dispatched')" + `WITH handles(handle) AS ( + SELECT to_handle FROM messages + UNION + SELECT from_handle FROM messages + ) + SELECT handle FROM handles + WHERE length(CAST(handle AS BLOB)) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} + AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE dispatch_contexts.assignee_handle = handles.handle + AND dispatch_contexts.status IN ('pending', 'dispatched') + ) + ORDER BY handle` ) - .all() as { assignee_handle: string }[] - const busyHandles = new Set(active.map((r) => r.assignee_handle)) - for (const h of excludeHandles) { - busyHandles.add(h) - } - // Return handles from message history that aren't busy - const allHandles = this.db - .prepare( - 'SELECT DISTINCT to_handle FROM messages UNION SELECT DISTINCT from_handle FROM messages' - ) - .all() as { to_handle: string }[] - return [...new Set(allHandles.map((r) => r.to_handle))].filter((h) => !busyHandles.has(h)) + .iterate() as Iterable<{ handle: string }> + const filtered = (function* (): Iterable<{ handle: string }> { + for (const row of rows) { + if (!excludeHandles.includes(row.handle)) { + yield row + } + } + })() + return retainOrchestrationQueryRows(filtered).map((row) => row.handle) } // ── Lifecycle ── diff --git a/src/main/runtime/orchestration/query-retention.ts b/src/main/runtime/orchestration/query-retention.ts new file mode 100644 index 00000000000..092e1cc6260 --- /dev/null +++ b/src/main/runtime/orchestration/query-retention.ts @@ -0,0 +1,124 @@ +import { + assertJsonTextStructureWithinLimits, + type JsonTextStructureLimits +} from '../../../shared/memory-safety/json-text-structure-limit' +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' + +export const ORCHESTRATION_QUERY_MAX_ROWS = 256 +export const ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES = 2 * 1024 * 1024 +export const ORCHESTRATION_QUERY_MAX_RETAINED_UTF8_BYTES = 8 * 1024 * 1024 +export const ORCHESTRATION_WRITE_MAX_UTF8_BYTES = 512 * 1024 +export const ORCHESTRATION_WRITE_MAX_ITEMS = 4096 +export const ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES = 1024 +export const ORCHESTRATION_JSON_STRUCTURE_LIMITS: JsonTextStructureLimits = { + structuralTokens: 64 * 1024, + nestingDepth: 64 +} + +export function parseOrchestrationJson(content: string): unknown { + assertJsonTextStructureWithinLimits(content, ORCHESTRATION_JSON_STRUCTURE_LIMITS) + return JSON.parse(content) as unknown +} + +export function clampOrchestrationQueryLimit( + requested: number | undefined, + fallback = ORCHESTRATION_QUERY_MAX_ROWS +): number { + if (requested === undefined || !Number.isFinite(requested)) { + return Math.min(fallback, ORCHESTRATION_QUERY_MAX_ROWS) + } + return Math.min(ORCHESTRATION_QUERY_MAX_ROWS, Math.max(0, Math.floor(requested))) +} + +export function orchestrationRowRetainedUtf8Bytes(row: object): number { + let bytes = 0 + for (const value of Object.values(row)) { + if (typeof value === 'string') { + bytes += Buffer.byteLength(value, 'utf8') + } + } + return bytes +} + +export function retainOrchestrationQueryRows( + rows: Iterable, + requestedLimit?: number +): T[] { + const limit = clampOrchestrationQueryLimit(requestedLimit) + if (limit === 0) { + return [] + } + const retained: T[] = [] + let retainedBytes = 0 + + for (const row of rows) { + const rowBytes = orchestrationRowRetainedUtf8Bytes(row) + if (rowBytes > ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES) { + continue + } + if (retainedBytes + rowBytes > ORCHESTRATION_QUERY_MAX_RETAINED_UTF8_BYTES) { + break + } + retained.push(row) + retainedBytes += rowBytes + if (retained.length >= limit) { + break + } + } + return retained +} + +export function assertOrchestrationWriteFits(label: string, values: unknown[]): void { + let retainedBytes = 0 + for (const value of values) { + if (typeof value !== 'string') { + continue + } + retainedBytes += Buffer.byteLength(value, 'utf8') + if (retainedBytes > ORCHESTRATION_WRITE_MAX_UTF8_BYTES) { + throw new Error( + `${label} exceeds the ${ORCHESTRATION_WRITE_MAX_UTF8_BYTES}-byte orchestration limit` + ) + } + } +} + +export function assertOrchestrationWaitTypeFilterFits(value: string | undefined): void { + if ( + value && + measureUtf8ByteLength(value, { + stopAfterBytes: ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES + }).exceededLimit + ) { + throw new Error( + `Message type filter exceeds the ${ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES}-byte orchestration wait limit` + ) + } +} + +export function assertOrchestrationStringListFits(label: string, values: string[]): void { + if (values.length > ORCHESTRATION_WRITE_MAX_ITEMS) { + throw new Error( + `${label} exceeds the ${ORCHESTRATION_WRITE_MAX_ITEMS}-item orchestration limit` + ) + } + assertOrchestrationWriteFits(label, values) +} + +export function truncateOrchestrationDiagnostic(value: string): string { + if (Buffer.byteLength(value, 'utf8') <= ORCHESTRATION_WRITE_MAX_UTF8_BYTES) { + return value + } + + let bytes = 0 + let end = 0 + for (const codePoint of value) { + const codePointBytes = Buffer.byteLength(codePoint, 'utf8') + if (bytes + codePointBytes > ORCHESTRATION_WRITE_MAX_UTF8_BYTES) { + break + } + bytes += codePointBytes + end += codePoint.length + } + return value.slice(0, end) +} diff --git a/src/main/runtime/recent-pty-output-buffer.test.ts b/src/main/runtime/recent-pty-output-buffer.test.ts index 6c21284e163..5b0dac0742b 100644 --- a/src/main/runtime/recent-pty-output-buffer.test.ts +++ b/src/main/runtime/recent-pty-output-buffer.test.ts @@ -93,6 +93,34 @@ describe('RecentPtyOutputBuffer', () => { expectEquivalent(chunks) }) + it('retains 100,000 tiny writes in compact payload storage and typed boundary metadata', () => { + const buffer = new RecentPtyOutputBuffer() + let reference: string | undefined + for (let index = 0; index < 100_000; index += 1) { + const chunk = String.fromCharCode(97 + (index % 26)) + buffer.append(chunk) + reference = referenceAppend(reference, chunk) + } + + const internals = buffer as unknown as { + chunks: string[] + originalChunkLengths: { length: number; capacity: number } + } + expect(internals.chunks.length).toBeLessThanOrEqual(1024) + expect(internals.originalChunkLengths.length).toBe(RECENT_PTY_OUTPUT_LIMIT) + expect(internals.originalChunkLengths.capacity).toBe(RECENT_PTY_OUTPUT_LIMIT) + expect(buffer.read()).toBe(reference) + + let streamedLength = 0 + let streamedChunks = 0 + buffer.forEachRetainedChunk((chunk) => { + streamedLength += chunk.length + streamedChunks += 1 + }) + expect(streamedLength).toBe(RECENT_PTY_OUTPUT_LIMIT) + expect(streamedChunks).toBe(RECENT_PTY_OUTPUT_LIMIT) + }) + it('matches the old UTF-16 slice behavior for multi-byte content at the boundary', () => { // JS string .slice counts UTF-16 code units, so trimming can split a // surrogate pair; the buffer must reproduce that split exactly. diff --git a/src/main/runtime/recent-pty-output-buffer.ts b/src/main/runtime/recent-pty-output-buffer.ts index a10559b75c9..e18c46b96dd 100644 --- a/src/main/runtime/recent-pty-output-buffer.ts +++ b/src/main/runtime/recent-pty-output-buffer.ts @@ -1,31 +1,69 @@ export const RECENT_PTY_OUTPUT_LIMIT = 64 * 1024 -// Compact the backing array once this many fully-dropped head slots accumulate, -// so the array itself stays bounded under long chunk floods. -const DROPPED_HEAD_COMPACT_THRESHOLD = 1024 +const RETAINED_CONTENT_CHUNK_LIMIT = 1024 +const DROPPED_CONTENT_CHUNK_LIMIT = 1024 +const INITIAL_BOUNDARY_CAPACITY = 16 -/** - * Bounded deque of raw PTY output chunks retaining exactly the last - * RECENT_PTY_OUTPUT_LIMIT UTF-16 code units. - * - * Why: eagerly rebuilding a rolling 64KB string per PTY chunk flattened a - * ~128KB rope on every write; keep chunks and defer the join to rare readers. - */ +class RetainedChunkLengthQueue { + private values = new Uint32Array(INITIAL_BOUNDARY_CAPACITY) + private head = 0 + private count = 0 + + get length(): number { + return this.count + } + + get capacity(): number { + return this.values.length + } + + push(value: number): void { + if (this.count === this.values.length) { + const grown = new Uint32Array(this.values.length * 2) + for (let index = 0; index < this.count; index += 1) { + grown[index] = this.at(index) + } + this.values = grown + this.head = 0 + } + this.values[(this.head + this.count) % this.values.length] = value + this.count += 1 + } + + shift(): number { + if (this.count === 0) { + return 0 + } + const value = this.values[this.head]! + this.head = (this.head + 1) % this.values.length + this.count -= 1 + if (this.count === 0) { + this.head = 0 + } + return value + } + + at(index: number): number { + return this.values[(this.head + index) % this.values.length]! + } + + reset(): void { + this.values = new Uint32Array(INITIAL_BOUNDARY_CAPACITY) + this.head = 0 + this.count = 0 + } +} + +/** Bounded raw PTY tail that temporarily preserves source chunk boundaries for path backfill. */ export class RecentPtyOutputBuffer { private chunks: string[] = [] - private headIndex = 0 - // Code units already trimmed off the front of the head chunk. Deferred so - // repeated small trims never allocate a substring per append, and so the - // head chunk's original text stays available for candidate backfill. - private headOffset = 0 + private contentHeadIndex = 0 + private contentHeadOffset = 0 private totalLen = 0 - // True when the stored head chunk is not the full original PTY chunk (a - // single over-limit append is stored pre-sliced), so backfill replay knows - // the original line context of its leading text is gone. + private headOffset = 0 private headChunkIsPartial = false - // Original chunk boundaries are owed only to the one-time path-candidate - // backfill; compact() ends that obligation and lets read() collapse. private preserveChunkBoundaries: boolean + private readonly originalChunkLengths = new RetainedChunkLengthQueue() constructor(options?: { preserveChunkBoundaries?: boolean }) { this.preserveChunkBoundaries = options?.preserveChunkBoundaries ?? true @@ -36,88 +74,145 @@ export class RecentPtyOutputBuffer { return } if (data.length >= RECENT_PTY_OUTPUT_LIMIT) { - this.chunks = [data.slice(-RECENT_PTY_OUTPUT_LIMIT)] - this.headIndex = 0 - this.headOffset = 0 + this.replaceContent(data.slice(-RECENT_PTY_OUTPUT_LIMIT)) this.totalLen = RECENT_PTY_OUTPUT_LIMIT + this.headOffset = 0 this.headChunkIsPartial = data.length > RECENT_PTY_OUTPUT_LIMIT + this.originalChunkLengths.reset() + if (this.preserveChunkBoundaries) { + this.originalChunkLengths.push(RECENT_PTY_OUTPUT_LIMIT) + } return } - this.chunks.push(data) + + this.appendContent(data) this.totalLen += data.length + if (this.preserveChunkBoundaries) { + this.trimPreservingBoundaries() + this.originalChunkLengths.push(data.length) + } else { + this.trimContentToWindow() + } + } + + read(): string { + const stored = this.storedContent() + const value = this.headOffset > 0 ? stored.slice(this.headOffset) : stored + if (!this.preserveChunkBoundaries) { + this.replaceContent(value) + } + return value + } + + /** Original PTY chunks retained for the one-time path-candidate backfill. */ + retainedChunks(): { chunks: string[]; headChunkIsPartial: boolean } { + const chunks: string[] = [] + const state = this.forEachRetainedChunk((chunk) => chunks.push(chunk)) + return { chunks, headChunkIsPartial: state.headChunkIsPartial } + } + + /** Streams boundaries so activation does not allocate one string object per tiny write. */ + forEachRetainedChunk( + visit: (chunk: string, index: number, headChunkIsPartial: boolean) => void + ): { + headChunkIsPartial: boolean + } { + if (!this.preserveChunkBoundaries) { + const value = this.read() + if (value) { + visit(value, 0, false) + } + return { headChunkIsPartial: false } + } + const stored = this.storedContent() + let offset = 0 + for (let index = 0; index < this.originalChunkLengths.length; index += 1) { + const length = this.originalChunkLengths.at(index) + visit(stored.slice(offset, offset + length), index, this.headChunkIsPartial) + offset += length + } + return { headChunkIsPartial: this.headChunkIsPartial } + } + + /** Ends the boundary obligation and returns to compact steady-state storage. */ + compact(): void { + const value = this.read() + this.preserveChunkBoundaries = false + this.replaceContent(value) + this.headOffset = 0 + this.headChunkIsPartial = false + this.originalChunkLengths.reset() + } + + private appendContent(data: string): void { + this.chunks.push(data) + if (this.chunks.length - this.contentHeadIndex > RETAINED_CONTENT_CHUNK_LIMIT) { + this.replaceContent(this.storedContent()) + } + } + + private trimPreservingBoundaries(): void { while (this.totalLen > RECENT_PTY_OUTPUT_LIMIT) { - const headRemaining = this.chunks[this.headIndex].length - this.headOffset + const originalHeadLength = this.originalChunkLengths.at(0) + const headRemaining = originalHeadLength - this.headOffset const excess = this.totalLen - RECENT_PTY_OUTPUT_LIMIT if (headRemaining <= excess) { - // Release the dropped chunk's reference; the slot is reclaimed on compaction. - this.chunks[this.headIndex] = '' - this.headIndex += 1 + this.totalLen -= headRemaining this.headOffset = 0 this.headChunkIsPartial = false - this.totalLen -= headRemaining + this.originalChunkLengths.shift() + this.dropContentPrefix(originalHeadLength) } else { this.headOffset += excess this.totalLen -= excess } } - if (this.headIndex >= DROPPED_HEAD_COMPACT_THRESHOLD) { - this.chunks = this.chunks.slice(this.headIndex) - this.headIndex = 0 - } } - read(): string { - if (this.preserveChunkBoundaries) { - // Join without mutating: boundaries and the original head chunk are - // still owed to retainedChunks(); reads are rare before compact(). - if (this.chunks.length - this.headIndex > 1) { - const retained = this.chunks.slice(this.headIndex) - if (this.headOffset > 0) { - retained[0] = retained[0].slice(this.headOffset) - } - return retained.join('') + private trimContentToWindow(): void { + const excess = this.totalLen - RECENT_PTY_OUTPUT_LIMIT + if (excess <= 0) { + return + } + this.dropContentPrefix(excess) + this.totalLen -= excess + } + + private dropContentPrefix(length: number): void { + let remaining = length + while (remaining > 0) { + const chunk = this.chunks[this.contentHeadIndex] ?? '' + const available = chunk.length - this.contentHeadOffset + if (available <= remaining) { + this.chunks[this.contentHeadIndex] = '' + this.contentHeadIndex += 1 + this.contentHeadOffset = 0 + remaining -= available + } else { + this.contentHeadOffset += remaining + remaining = 0 } - const head = this.chunks[this.headIndex] ?? '' - return this.headOffset > 0 ? head.slice(this.headOffset) : head } - if (this.chunks.length - this.headIndex > 1) { - // Collapse to the joined tail so repeated reads stay O(1). - const retained = this.chunks.slice(this.headIndex) - if (this.headOffset > 0) { - retained[0] = retained[0].slice(this.headOffset) - this.headOffset = 0 - } - this.chunks = [retained.join('')] - this.headIndex = 0 - } else if (this.headOffset > 0) { - // Single retained chunk: apply the deferred head trim once, here. - this.chunks[this.headIndex] = this.chunks[this.headIndex].slice(this.headOffset) - this.headOffset = 0 - } - return this.chunks[this.headIndex] ?? '' - } - - /** - * Retained chunks with original PTY boundaries. The head chunk is its full - * original text (any window-trimmed prefix included) unless - * headChunkIsPartial. Why: path-candidate backfill must replay the eager - * per-chunk extraction exactly — trimming or joining chunks changes the - * candidate set. Only meaningful before compact(). - */ - retainedChunks(): { chunks: string[]; headChunkIsPartial: boolean } { - return { - chunks: this.chunks.slice(this.headIndex), - headChunkIsPartial: this.headChunkIsPartial + if (this.contentHeadIndex >= DROPPED_CONTENT_CHUNK_LIMIT) { + this.chunks = this.chunks.slice(this.contentHeadIndex) + this.contentHeadIndex = 0 } } - /** - * Ends the chunk-boundary obligation after the one-time backfill and - * collapses immediately, so the append/read hot path returns to the - * compact single-chunk steady state. - */ - compact(): void { - this.preserveChunkBoundaries = false - this.read() + private storedContent(): string { + const retained = this.chunks.slice(this.contentHeadIndex) + if (retained.length === 0) { + return '' + } + if (this.contentHeadOffset > 0) { + retained[0] = retained[0]!.slice(this.contentHeadOffset) + } + return retained.length === 1 ? retained[0]! : retained.join('') + } + + private replaceContent(value: string): void { + this.chunks = value ? [value] : [] + this.contentHeadIndex = 0 + this.contentHeadOffset = 0 } } diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index d06a7702169..1169fbc3b65 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -2,8 +2,13 @@ import { createHash, createHmac, randomBytes } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' import { WebSocketServer, type WebSocket } from 'ws' +import { JsonStringifyByteLimitError } from '../../../shared/memory-safety/node-bounded-json-stringify' import type { E2EEKeypair } from '../e2ee-keypair' -import { RelayControlClient } from './relay-control-client' +import { + RELAY_CONTROL_MAX_MESSAGE_BYTES, + RelayControlClient, + serializeRelayControlMessage +} from './relay-control-client' const encoder = new TextEncoder() const HOST_PROOF_DOMAIN = 'orca-relay-host-proof/v1' @@ -80,6 +85,21 @@ describe('RelayControlClient', () => { const servers: WebSocketServer[] = [] const clients: RelayControlClient[] = [] + it('preserves ordinary control message serialization byte-for-byte', () => { + const payload = { type: 'auth-refresh', relayJwt: 'token-😀' } + + expect(serializeRelayControlMessage(payload)).toBe(JSON.stringify(payload)) + }) + + it('rejects an oversized control message during bounded serialization', () => { + expect(() => + serializeRelayControlMessage({ + type: 'auth-refresh', + relayJwt: 'x'.repeat(RELAY_CONTROL_MAX_MESSAGE_BYTES) + }) + ).toThrow(JsonStringifyByteLimitError) + }) + afterEach(async () => { for (const client of clients.splice(0)) { client.closeNow() @@ -97,72 +117,6 @@ describe('RelayControlClient', () => { ) }) - it('rejects a control handshake that never receives a proof response', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) - servers.push(server) - await new Promise((resolve) => server.once('listening', resolve)) - const address = server.address() - if (!address || typeof address === 'string') { - throw new Error('expected TCP relay test server') - } - const keypair = nacl.box.keyPair() - const client = new RelayControlClient({ - cellUrl: `http://127.0.0.1:${address.port}`, - relayJwt: 'scoped-token', - relayHostId: createHash('sha256').update(keypair.publicKey).digest('base64url').slice(0, 16), - assignmentEpoch: 1, - identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, - keypair: { - ...keypair, - publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') - }, - appVersion: '1.2.3', - onConnectionOpen: vi.fn(), - onDrain: vi.fn(), - onClose: vi.fn(), - connectDeadlineMs: 20 - }) - clients.push(client) - - await expect(client.connect()).rejects.toThrow('relay_control_connect_timeout') - }) - - it('settles an opening control immediately when ownership closes', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) - servers.push(server) - await new Promise((resolve) => server.once('listening', resolve)) - const address = server.address() - if (!address || typeof address === 'string') { - throw new Error('expected TCP relay test server') - } - const keypair = nacl.box.keyPair() - const client = new RelayControlClient({ - cellUrl: `http://127.0.0.1:${address.port}`, - relayJwt: 'scoped-token', - relayHostId: createHash('sha256').update(keypair.publicKey).digest('base64url').slice(0, 16), - assignmentEpoch: 1, - identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, - keypair: { - ...keypair, - publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') - }, - appVersion: '1.2.3', - onConnectionOpen: vi.fn(), - onDrain: vi.fn(), - onClose: vi.fn() - }) - clients.push(client) - const accepted = new Promise((resolve) => { - server.once('connection', () => resolve()) - }) - const connecting = client.connect() - - await accepted - client.closeNow() - - await expect(connecting).rejects.toThrow('relay_control_closed') - }) - it('proves the host key and drives control/data commands without URL credentials', async () => { const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) servers.push(server) diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts index f4ba141ae0d..0e669d213bc 100644 --- a/src/main/runtime/relay/relay-control-client.ts +++ b/src/main/runtime/relay/relay-control-client.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import WebSocket, { type RawData } from 'ws' import { MOBILE_RELAY_CLOSE_CODE } from '../../../shared/mobile-relay-close-codes' +import { stringifyJsonWithinByteLimit } from '../../../shared/memory-safety/node-bounded-json-stringify' import type { E2EEKeypair } from '../e2ee-keypair' import { RelayConnectionOpenMessageSchema, @@ -19,6 +20,7 @@ import type { DeviceCredentialInstallAuthorization } from './relay-control-reque import { answerRelayHostChallenge } from './relay-host-proof' type RelayControlState = 'idle' | 'opening' | 'proving' | 'active' | 'draining' | 'closed' +export const RELAY_CONTROL_MAX_MESSAGE_BYTES = 64 * 1024 type RelayControlClientOptions = { cellUrl: string @@ -34,11 +36,8 @@ type RelayControlClientOptions = { onDrain: (message: RelayDrainMessage) => void onClose: (code: number) => void createSocket?: (url: string, relayJwt: string) => WebSocket - connectDeadlineMs?: number } -const RELAY_CONTROL_CONNECT_DEADLINE_MS = 15_000 - function controlWebSocketUrl(cellUrl: string): { origin: string; url: string } { const parsed = new URL(cellUrl) if (parsed.pathname !== '/' || parsed.search || parsed.hash) { @@ -65,7 +64,6 @@ export class RelayControlClient { private state: RelayControlState = 'idle' private connectResolve: ((ack: RelayHostHelloAckMessage) => void) | null = null private connectReject: ((error: Error) => void) | null = null - private connectTimer: ReturnType | null = null constructor(options: RelayControlClientOptions) { this.options = options @@ -78,7 +76,7 @@ export class RelayControlClient { new WebSocket(url, { headers: { authorization: `Bearer ${token}` }, perMessageDeflate: false, - maxPayload: 64 * 1024 + maxPayload: RELAY_CONTROL_MAX_MESSAGE_BYTES })) } @@ -104,12 +102,6 @@ export class RelayControlClient { } }) socket.once('close', (code) => this.handleClose(code)) - // Recovery cannot advance while an upgrade/proof promise remains pending forever. - this.connectTimer = setTimeout( - () => this.expireConnect(), - this.options.connectDeadlineMs ?? RELAY_CONTROL_CONNECT_DEADLINE_MS - ) - this.connectTimer.unref() return new Promise((resolve, reject) => { this.connectResolve = resolve this.connectReject = reject @@ -163,12 +155,7 @@ export class RelayControlClient { } closeNow(): void { - const wasConnecting = this.state === 'opening' || this.state === 'proving' this.state = 'closed' - if (wasConnecting) { - this.connectReject?.(new Error('relay_control_closed')) - this.clearConnectPromise() - } this.requests.rejectAll(new Error('relay_control_closed')) this.socket?.terminate() this.socket = null @@ -180,7 +167,7 @@ export class RelayControlClient { } this.state = 'proving' this.socket.send( - JSON.stringify({ + serializeRelayControlMessage({ type: 'host-hello', v: 1, relayHostId: this.options.relayHostId, @@ -212,7 +199,7 @@ export class RelayControlClient { return } if (RelayPingMessageSchema.safeParse(message).success) { - this.socket?.send(JSON.stringify({ type: 'pong', t: message.t })) + this.socket?.send(serializeRelayControlMessage({ type: 'pong', t: message.t })) return } const connection = RelayConnectionOpenMessageSchema.safeParse(message) @@ -250,7 +237,7 @@ export class RelayControlClient { return } this.socket?.send( - JSON.stringify({ + serializeRelayControlMessage({ type: 'host-challenge-ack', challengeId: challenge.data.challengeId, proofB64 @@ -272,7 +259,7 @@ export class RelayControlClient { if (!this.socket || (this.state !== 'active' && this.state !== 'draining')) { throw new Error('relay_control_not_active') } - this.socket.send(JSON.stringify(payload)) + this.socket.send(serializeRelayControlMessage(payload)) } private failProtocol(reason: string): void { @@ -292,21 +279,12 @@ export class RelayControlClient { this.options.onClose(code) } - private expireConnect(): void { - if (this.state !== 'opening' && this.state !== 'proving') { - return - } - this.connectReject?.(new Error('relay_control_connect_timeout')) - this.clearConnectPromise() - this.socket?.terminate() - } - private clearConnectPromise(): void { - if (this.connectTimer) { - clearTimeout(this.connectTimer) - this.connectTimer = null - } this.connectResolve = null this.connectReject = null } } + +export function serializeRelayControlMessage(payload: unknown): string { + return stringifyJsonWithinByteLimit(payload, RELAY_CONTROL_MAX_MESSAGE_BYTES).serialized +} diff --git a/src/main/runtime/relay/relay-control-requests.test.ts b/src/main/runtime/relay/relay-control-requests.test.ts new file mode 100644 index 00000000000..bafa0611d35 --- /dev/null +++ b/src/main/runtime/relay/relay-control-requests.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + RELAY_CONTROL_MAX_PENDING_REQUESTS, + RELAY_CONTROL_REQUEST_ID_MAX_UTF8_BYTES, + RelayControlRequests +} from './relay-control-requests' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('RelayControlRequests admission', () => { + it('fails closed at the pending request cap', async () => { + vi.useFakeTimers() + const requests = new RelayControlRequests() + const send = vi.fn() + const admitted = Array.from({ length: RELAY_CONTROL_MAX_PENDING_REQUESTS }, (_, index) => + requests.createInvite(`request-${index}`, 'device-a', send) + ) + const settlements = Promise.allSettled(admitted) + + await expect(requests.createInvite('request-overflow', 'device-a', send)).rejects.toThrow( + 'relay_control_request_limit' + ) + expect(requests.size).toBe(RELAY_CONTROL_MAX_PENDING_REQUESTS) + expect(send).toHaveBeenCalledTimes(RELAY_CONTROL_MAX_PENDING_REQUESTS) + + requests.rejectAll(new Error('closed')) + await settlements + expect(requests.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('rejects an oversized request id before sending or retaining it', async () => { + const requests = new RelayControlRequests() + const send = vi.fn() + + await expect( + requests.createInvite('🙂'.repeat(RELAY_CONTROL_REQUEST_ID_MAX_UTF8_BYTES), 'device-a', send) + ).rejects.toThrow('relay_control_request_limit') + + expect(requests.size).toBe(0) + expect(send).not.toHaveBeenCalled() + }) + + it('releases admission when send throws', async () => { + vi.useFakeTimers() + const requests = new RelayControlRequests() + + await expect( + requests.createInvite('request-a', 'device-a', () => { + throw new Error('send failed') + }) + ).rejects.toThrow('send failed') + + expect(requests.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('releases admission when a request times out', async () => { + vi.useFakeTimers() + const requests = new RelayControlRequests() + const result = requests.createInvite('request-a', 'device-a', () => {}) + const rejection = expect(result).rejects.toThrow('relay_control_request_timeout') + + await vi.advanceTimersByTimeAsync(10_000) + + await rejection + expect(requests.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/runtime/relay/relay-control-requests.ts b/src/main/runtime/relay/relay-control-requests.ts index 2a96b94e3ec..36629a10239 100644 --- a/src/main/runtime/relay/relay-control-requests.ts +++ b/src/main/runtime/relay/relay-control-requests.ts @@ -16,14 +16,21 @@ type PendingRequest = { resolve: (value: unknown) => void reject: (error: Error) => void timer: ReturnType + reqIdBytes: number } +export const RELAY_CONTROL_MAX_PENDING_REQUESTS = 256 +export const RELAY_CONTROL_REQUEST_ID_MAX_UTF8_BYTES = 512 +export const RELAY_CONTROL_RETAINED_REQUEST_ID_MAX_UTF8_BYTES = + RELAY_CONTROL_MAX_PENDING_REQUESTS * RELAY_CONTROL_REQUEST_ID_MAX_UTF8_BYTES + export type DeviceCredentialInstallAuthorization = | { mode: 'relay-basis'; basisConnId: string } | { mode: 'authenticated-direct'; directAuthId: string } export class RelayControlRequests { private readonly pending = new Map() + private retainedRequestIdBytes = 0 get size(): number { return this.pending.size @@ -160,12 +167,22 @@ export class RelayControlRequests { if (this.pending.has(reqId)) { return Promise.reject(new Error('duplicate_relay_request_id')) } + const reqIdBytes = Buffer.byteLength(reqId, 'utf8') + if ( + reqId.length === 0 || + reqIdBytes > RELAY_CONTROL_REQUEST_ID_MAX_UTF8_BYTES || + this.pending.size >= RELAY_CONTROL_MAX_PENDING_REQUESTS || + reqIdBytes > RELAY_CONTROL_RETAINED_REQUEST_ID_MAX_UTF8_BYTES - this.retainedRequestIdBytes + ) { + return Promise.reject(new Error('relay_control_request_limit')) + } return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.pending.delete(reqId) + this.finish(reqId) reject(new Error('relay_control_request_timeout')) }, 10_000) - this.pending.set(reqId, { kind, resolve, reject, timer }) + this.pending.set(reqId, { kind, resolve, reject, timer, reqIdBytes }) + this.retainedRequestIdBytes += reqIdBytes try { send(payload) } catch (error) { @@ -180,6 +197,7 @@ export class RelayControlRequests { if (pending) { clearTimeout(pending.timer) this.pending.delete(reqId) + this.retainedRequestIdBytes -= pending.reqIdBytes } } } diff --git a/src/main/runtime/relay/relay-host-proof-bounds.test.ts b/src/main/runtime/relay/relay-host-proof-bounds.test.ts new file mode 100644 index 00000000000..602b1bee44b --- /dev/null +++ b/src/main/runtime/relay/relay-host-proof-bounds.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import { + answerRelayHostChallenge, + RELAY_HOST_CHALLENGE_MAX_CIPHERTEXT_BASE64_CHARACTERS +} from './relay-host-proof' + +describe('relay host proof base64 admission', () => { + it('rejects oversized fixed-width fields before decoding', () => { + const decode = vi.spyOn(Buffer, 'from') + + expect( + answerRelayHostChallenge( + { + challengeId: 'challenge', + relayEphemeralPublicKeyB64: 'A'.repeat(45), + nonceB64: 'A'.repeat(33), + ciphertextB64: 'AAAA', + expiresAt: Date.now() + 1_000 + }, + context() + ) + ).toBeNull() + expect(decode).not.toHaveBeenCalled() + decode.mockRestore() + }) + + it('rejects oversized ciphertext before decoding that field', () => { + const relayKey = Buffer.from(new Uint8Array(32)).toString('base64') + const nonce = Buffer.from(new Uint8Array(24)).toString('base64') + const decode = vi.spyOn(Buffer, 'from') + + expect( + answerRelayHostChallenge( + { + challengeId: 'challenge', + relayEphemeralPublicKeyB64: relayKey, + nonceB64: nonce, + ciphertextB64: 'A'.repeat(RELAY_HOST_CHALLENGE_MAX_CIPHERTEXT_BASE64_CHARACTERS + 1), + expiresAt: Date.now() + 1_000 + }, + context() + ) + ).toBeNull() + expect(decode).toHaveBeenCalledTimes(2) + decode.mockRestore() + }) +}) + +function context() { + const host = nacl.box.keyPair() + return { + relayOrigin: 'https://relay.example', + userId: 'user', + profileId: 'profile', + organizationId: 'organization', + relayHostId: 'host', + hostPublicKey: host.publicKey, + hostSecretKey: host.secretKey, + assignmentEpoch: 1, + resumeRequested: false + } +} diff --git a/src/main/runtime/relay/relay-host-proof.ts b/src/main/runtime/relay/relay-host-proof.ts index 94db5b2f1b2..9fcc29ed9d9 100644 --- a/src/main/runtime/relay/relay-host-proof.ts +++ b/src/main/runtime/relay/relay-host-proof.ts @@ -5,6 +5,7 @@ const HOST_PROOF_TRANSCRIPT_DOMAIN = 'orca-relay-host-proof/v1' const HOST_CHALLENGE_PLAINTEXT_DOMAIN = 'orca-relay-host-challenge/v1' const textEncoder = new TextEncoder() const textDecoder = new TextDecoder() +export const RELAY_HOST_CHALLENGE_MAX_CIPHERTEXT_BASE64_CHARACTERS = 16 * 1024 export type RelayHostChallenge = { challengeId: string @@ -29,7 +30,10 @@ export type RelayHostProofContext = { } function decodeCanonicalBase64(value: string, expectedBytes: number): Uint8Array | null { - if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + if ( + value.length !== Math.ceil(expectedBytes / 3) * 4 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { return null } const decoded = Buffer.from(value, 'base64') @@ -129,8 +133,18 @@ export function answerRelayHostChallenge( ): string | null { const relayKey = decodeCanonicalBase64(challenge.relayEphemeralPublicKeyB64, 32) const nonce = decodeCanonicalBase64(challenge.nonceB64, 24) + if ( + !relayKey || + !nonce || + challenge.ciphertextB64.length > RELAY_HOST_CHALLENGE_MAX_CIPHERTEXT_BASE64_CHARACTERS || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + challenge.ciphertextB64 + ) + ) { + return null + } const ciphertext = Buffer.from(challenge.ciphertextB64, 'base64') - if (!relayKey || !nonce || ciphertext.toString('base64') !== challenge.ciphertextB64) { + if (ciphertext.toString('base64') !== challenge.ciphertextB64) { return null } const plaintext = nacl.box.open(ciphertext, nonce, relayKey, context.hostSecretKey) diff --git a/src/main/runtime/relay/relay-http-client.test.ts b/src/main/runtime/relay/relay-http-client.test.ts index 9a0590adb74..68e27b157ac 100644 --- a/src/main/runtime/relay/relay-http-client.test.ts +++ b/src/main/runtime/relay/relay-http-client.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' +import { + API_RESPONSE_MAX_BYTES, + FetchResponseBodyTooLargeError +} from '../../lib/fetch-response-body' import { cancelTrackingResponse } from '../../lib/unread-response-body.test-fixtures' import { exchangeRelayAuthorization, requestRelayAssignment } from './relay-http-client' @@ -53,49 +57,6 @@ describe('relay HTTP client', () => { expect(fetch.mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: 'Bearer scoped-token' }) - expect(fetch.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) - }) - - it('aborts a blackholed assignment request so recovery can retry', async () => { - const fetch = vi.fn( - async (_url, init) => - await new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }) - }) - ) - - await expect( - requestRelayAssignment({ - directorUrl: 'https://relay.example', - relayToken: 'scoped-token', - relayHostId: 'AbCdEf0123_-xyZ9', - requestDeadlineMs: 5, - fetch - }) - ).rejects.toMatchObject({ name: 'TimeoutError' }) - }) - - it('aborts a blackholed token exchange so recovery can retry', async () => { - const keypair = nacl.box.keyPair() - const fetch = vi.fn( - async (_url, init) => - await new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }) - }) - ) - - await expect( - exchangeRelayAuthorization({ - endpoint: 'https://auth.example/v1/desktop/auth/relay-token', - accessToken: 'ordinary-access-token', - keypair: { - ...keypair, - publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') - }, - requestDeadlineMs: 5, - fetch - }) - ).rejects.toMatchObject({ name: 'TimeoutError' }) }) it('rejects data-plane supplied non-origin URLs', async () => { @@ -147,4 +108,30 @@ describe('relay HTTP client', () => { ).rejects.toThrow() expect(cancelledBodies).toBe(2) }) + + it('rejects oversized successful responses from injected standard fetch clients', async () => { + let cancelled = false + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([123])) + }, + cancel() { + cancelled = true + } + }), + { headers: { 'content-length': String(API_RESPONSE_MAX_BYTES + 1) } } + ) + const fetch = vi.fn(async () => response) + + await expect( + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + fetch + }) + ).rejects.toBeInstanceOf(FetchResponseBodyTooLargeError) + expect(cancelled).toBe(true) + }) }) diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index a9b1120d784..6862f26ce6c 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -2,8 +2,7 @@ import { createHash } from 'node:crypto' import { z } from 'zod' import type { E2EEKeypair } from '../e2ee-keypair' import { cancelUnreadResponseBody } from '../../lib/unread-response-body' - -const RELAY_HTTP_REQUEST_DEADLINE_MS = 15_000 +import { readFetchResponseJsonWithinLimit } from '../../lib/fetch-response-body' const RelayTokenResponseSchema = z .object({ @@ -73,7 +72,6 @@ export async function exchangeRelayAuthorization(input: { accessToken: string keypair: E2EEKeypair fetch?: typeof globalThis.fetch - requestDeadlineMs?: number }): Promise { const relayHostId = deriveRelayHostId(input.keypair.publicKey) const response = await (input.fetch ?? globalThis.fetch)(input.endpoint, { @@ -82,15 +80,15 @@ export async function exchangeRelayAuthorization(input: { authorization: `Bearer ${input.accessToken}`, 'content-type': 'application/json' }, - // A blackholed request must settle so the coordinator can advance its bounded retry state. - signal: AbortSignal.timeout(input.requestDeadlineMs ?? RELAY_HTTP_REQUEST_DEADLINE_MS), body: JSON.stringify({ relayHostId, hostPublicKeyB64: input.keypair.publicKeyB64 }) }) if (!response.ok) { await cancelUnreadResponseBody(response) throw new RelayHttpError('token-exchange', response.status) } - const parsed = RelayTokenResponseSchema.safeParse(await response.json()) + const parsed = RelayTokenResponseSchema.safeParse( + await readFetchResponseJsonWithinLimit(response) + ) if (!parsed.success) { throw new RelayHttpError('token-exchange', 502) } @@ -102,7 +100,6 @@ export async function requestRelayAssignment(input: { relayToken: string relayHostId: string fetch?: typeof globalThis.fetch - requestDeadlineMs?: number }): Promise { if (!isAllowedRelayOrigin(input.directorUrl)) { throw new RelayHttpError('assignment', 400) @@ -113,14 +110,15 @@ export async function requestRelayAssignment(input: { authorization: `Bearer ${input.relayToken}`, 'content-type': 'application/json' }, - signal: AbortSignal.timeout(input.requestDeadlineMs ?? RELAY_HTTP_REQUEST_DEADLINE_MS), body: JSON.stringify({ v: 1, relayHostId: input.relayHostId }) }) if (!response.ok) { await cancelUnreadResponseBody(response) throw new RelayHttpError('assignment', response.status) } - const parsed = AssignmentResponseSchema.safeParse(await response.json()) + const parsed = AssignmentResponseSchema.safeParse( + await readFetchResponseJsonWithinLimit(response) + ) if (!parsed.success || !isAllowedRelayOrigin(parsed.data.cellUrl)) { throw new RelayHttpError('assignment', 502) } diff --git a/src/main/runtime/relay/relay-revoke-outbox.test.ts b/src/main/runtime/relay/relay-revoke-outbox.test.ts index 54ab0473049..a4a4724164f 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.test.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.test.ts @@ -1,8 +1,14 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { RelayRevokeOutbox } from './relay-revoke-outbox' +import { + MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES, + MAX_RELAY_REVOKE_OUTBOX_ITEMS, + RelayRevokeOutbox, + RelayRevokeOutboxCapacityError, + type RelayRevokeOutboxItem +} from './relay-revoke-outbox' describe('RelayRevokeOutbox', () => { const paths: string[] = [] @@ -29,4 +35,70 @@ describe('RelayRevokeOutbox', () => { new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId) ).toEqual([]) }) + + it('treats an oversized sparse outbox file as unavailable', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-bound-')) + paths.push(path) + const outboxPath = join(path, 'mobile-relay-revoke-outbox.json') + writeFileSync(outboxPath, '[]') + truncateSync(outboxPath, MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES + 1) + + expect(new RelayRevokeOutbox(path).pendingFor('owner', 'host')).toEqual([]) + }) + + it('fails closed instead of dropping revocations beyond the retained-item bound', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-count-')) + paths.push(path) + const items: RelayRevokeOutboxItem[] = Array.from( + { length: MAX_RELAY_REVOKE_OUTBOX_ITEMS + 1 }, + (_, index) => ({ + reqId: `request-${index}`, + relayHostId: 'host', + relayDeviceId: `device-${index}`, + ownerIdentityKey: 'owner', + createdAt: index + }) + ) + writeFileSync(join(path, 'mobile-relay-revoke-outbox.json'), JSON.stringify(items)) + + expect(() => new RelayRevokeOutbox(path)).toThrow(RelayRevokeOutboxCapacityError) + }) + + it('keeps idempotent revokes usable at capacity and rejects only a new revoke', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-capacity-')) + paths.push(path) + const items: RelayRevokeOutboxItem[] = Array.from( + { length: MAX_RELAY_REVOKE_OUTBOX_ITEMS }, + (_, index) => ({ + reqId: `request-${index}`, + relayHostId: 'host', + relayDeviceId: `device-${index}`, + ownerIdentityKey: 'owner', + createdAt: index + }) + ) + writeFileSync(join(path, 'mobile-relay-revoke-outbox.json'), JSON.stringify(items)) + const outbox = new RelayRevokeOutbox(path) + + expect(outbox.enqueue(items[0]!).reqId).toBe(items[0]!.reqId) + expect(() => + outbox.enqueue({ relayHostId: 'host', relayDeviceId: 'new', ownerIdentityKey: 'owner' }) + ).toThrow(RelayRevokeOutboxCapacityError) + }) + + it('rejects a byte-oversized revoke without publishing partial in-memory state', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-byte-capacity-')) + paths.push(path) + const outbox = new RelayRevokeOutbox(path) + + expect(() => + outbox.enqueue({ + relayHostId: 'host', + relayDeviceId: 'device', + ownerIdentityKey: 'x'.repeat(MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES) + }) + ).toThrow(RelayRevokeOutboxCapacityError) + expect(outbox.pendingFor('owner', 'host')).toEqual([]) + expect(new RelayRevokeOutbox(path).pendingFor('owner', 'host')).toEqual([]) + }) }) diff --git a/src/main/runtime/relay/relay-revoke-outbox.ts b/src/main/runtime/relay/relay-revoke-outbox.ts index 8a7e8cc19bb..2e1afa1cd1f 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.ts @@ -1,7 +1,10 @@ import { randomUUID } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { join } from 'node:path' -import { hardenExistingSecureFile, writeSecureJsonFile } from '../../../shared/secure-file' +import { readNodeFileSyncWithinLimit } from '../../../shared/memory-safety/node-bounded-file-reader' +import { JsonStringifyByteLimitError } from '../../../shared/memory-safety/node-bounded-json-stringify' +import { writeSecureJsonFileWithinLimit } from '../../../shared/memory-safety/bounded-secure-json-file' +import { hardenExistingSecureFile } from '../../../shared/secure-file' export type RelayDeviceBinding = { relayHostId: string @@ -16,6 +19,15 @@ export type RelayRevokeOutboxItem = RelayDeviceBinding & { } const OUTBOX_FILENAME = 'mobile-relay-revoke-outbox.json' +export const MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES = 1024 * 1024 +export const MAX_RELAY_REVOKE_OUTBOX_ITEMS = 4096 + +export class RelayRevokeOutboxCapacityError extends Error { + constructor() { + super(`Relay revoke outbox exceeds ${MAX_RELAY_REVOKE_OUTBOX_ITEMS} items`) + this.name = 'RelayRevokeOutboxCapacityError' + } +} function isItem(value: unknown): value is RelayRevokeOutboxItem { if (!value || typeof value !== 'object') { @@ -53,9 +65,13 @@ export class RelayRevokeOutbox { if (existing) { return existing } + if (this.items.length >= MAX_RELAY_REVOKE_OUTBOX_ITEMS) { + throw new RelayRevokeOutboxCapacityError() + } const item = { ...binding, reqId: randomUUID(), createdAt: Date.now() } - this.items.push(item) - this.save() + const next = [...this.items, item] + this.save(next) + this.items = next return item } @@ -70,24 +86,44 @@ export class RelayRevokeOutbox { if (next.length === this.items.length) { return } + this.save(next) this.items = next - this.save() } private load(): RelayRevokeOutboxItem[] { if (!existsSync(this.path)) { return [] } + let parsed: unknown try { hardenExistingSecureFile(this.path) - const parsed: unknown = JSON.parse(readFileSync(this.path, 'utf-8')) - return Array.isArray(parsed) ? parsed.filter(isItem) : [] + parsed = JSON.parse( + readNodeFileSyncWithinLimit(this.path, MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES).buffer.toString( + 'utf8' + ) + ) } catch { return [] } + if (!Array.isArray(parsed)) { + return [] + } + const items = parsed.filter(isItem) + if (items.length > MAX_RELAY_REVOKE_OUTBOX_ITEMS) { + // Why: silently dropping durable revocations could leave remote credentials active. + throw new RelayRevokeOutboxCapacityError() + } + return items } - private save(): void { - writeSecureJsonFile(this.path, this.items) + private save(items: RelayRevokeOutboxItem[]): void { + try { + writeSecureJsonFileWithinLimit(this.path, items, MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES) + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + throw new RelayRevokeOutboxCapacityError() + } + throw error + } } } diff --git a/src/main/runtime/rpc/dispatcher-output-bounds.test.ts b/src/main/runtime/rpc/dispatcher-output-bounds.test.ts new file mode 100644 index 00000000000..2bab56bbd08 --- /dev/null +++ b/src/main/runtime/rpc/dispatcher-output-bounds.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' +import type { OrcaRuntimeService } from '../orca-runtime' +import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' + +const request = (method: string): RpcRequest => ({ + id: 'request-1', + authToken: 'unused', + method +}) + +function runtime(): OrcaRuntimeService { + return { + getRuntimeId: () => 'runtime-1', + recordFeatureInteraction: vi.fn() + } as unknown as OrcaRuntimeService +} + +describe('RpcDispatcher outbound response bounds', () => { + it('preserves ordinary streaming response serialization byte-for-byte', async () => { + const dispatcher = new RpcDispatcher({ + runtime: runtime(), + methods: [ + defineMethod({ + name: 'test.normal', + params: null, + handler: () => ({ text: 'hello 😀' }) + }) + ] + }) + const reply = vi.fn() + + await dispatcher.dispatchStreaming(request('test.normal'), reply) + + expect(reply).toHaveBeenCalledWith( + JSON.stringify({ + id: 'request-1', + ok: true, + result: { text: 'hello 😀' }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + }) + + it('replaces an oversized one-shot result with a small correlated error', async () => { + const dispatcher = new RpcDispatcher({ + runtime: runtime(), + methods: [ + defineMethod({ + name: 'test.oversized', + params: null, + handler: () => ({ text: 'x'.repeat(REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES) }) + }) + ] + }) + + const response = await dispatcher.dispatch(request('test.oversized')) + + expect(response).toMatchObject({ + id: 'request-1', + ok: false, + error: { code: 'response_too_large' }, + _meta: { runtimeId: 'runtime-1' } + }) + expect(Buffer.byteLength(JSON.stringify(response), 'utf8')).toBeLessThan( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + }) + + it('bounds an oversized streaming emission before calling the reply transport', async () => { + const dispatcher = new RpcDispatcher({ + runtime: runtime(), + methods: [ + defineStreamingMethod({ + name: 'test.stream-oversized', + params: null, + handler: async (_params, _context, emit) => { + emit({ text: 'x'.repeat(REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES) }) + } + }) + ] + }) + const reply = vi.fn() + + await dispatcher.dispatchStreaming(request('test.stream-oversized'), reply) + + expect(reply).toHaveBeenCalledOnce() + const response = JSON.parse(reply.mock.calls[0]![0] as string) as { + id: string + ok: boolean + error: { code: string } + } + expect(response).toMatchObject({ + id: 'request-1', + ok: false, + error: { code: 'response_too_large' } + }) + }) +}) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 5b677088074..da6c9ab2237 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -29,6 +29,10 @@ import { import { ALL_RPC_METHODS } from './methods' import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe' import type { OrcaRuntimeService } from '../orca-runtime' +import { + boundRuntimeRpcResponse, + serializeRuntimeRpcResponse +} from './runtime-rpc-response-serialization' export type DispatcherOptions = { runtime: OrcaRuntimeService @@ -48,28 +52,27 @@ export class RpcDispatcher { const meta = this.meta() const method = this.registry.get(request.method) if (!method) { - return errorResponse( - request.id, - meta, - 'method_not_found', - `Unknown method: ${request.method}` + return boundRuntimeRpcResponse( + errorResponse(request.id, meta, 'method_not_found', `Unknown method: ${request.method}`) ) } const parsedParams = this.parseParams(request, method, meta) if (parsedParams.error) { - return parsedParams.error + return boundRuntimeRpcResponse(parsedParams.error) } // Why: streaming methods are not supported over one-shot transports like // Unix sockets. They require a reply function that can be called multiple // times, which is only available via dispatchStreaming. if (isStreamingMethod(method)) { - return errorResponse( - request.id, - meta, - 'method_not_supported', - `Method ${request.method} requires a streaming transport` + return boundRuntimeRpcResponse( + errorResponse( + request.id, + meta, + 'method_not_supported', + `Method ${request.method} requires a streaming transport` + ) ) } @@ -83,12 +86,12 @@ export class RpcDispatcher { signal: options?.signal }) this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params) - return successResponse(request.id, meta, result) + return boundRuntimeRpcResponse(successResponse(request.id, meta, result)) } catch (error) { if (isEmulator) { emulatorProbeError(`rpc ${request.method}`, error, { params: request.params }) } - return this.mapError(request, meta, error) + return boundRuntimeRpcResponse(this.mapError(request, meta, error)) } } @@ -116,7 +119,7 @@ export class RpcDispatcher { const method = this.registry.get(request.method) if (!method) { reply( - JSON.stringify( + serializeRuntimeRpcResponse( errorResponse(request.id, meta, 'method_not_found', `Unknown method: ${request.method}`) ) ) @@ -125,7 +128,7 @@ export class RpcDispatcher { const parsedParams = this.parseParams(request, method, meta) if (parsedParams.error) { - reply(JSON.stringify(parsedParams.error)) + reply(serializeRuntimeRpcResponse(parsedParams.error)) return } @@ -144,9 +147,9 @@ export class RpcDispatcher { registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params) - reply(JSON.stringify(successResponse(request.id, meta, result))) + reply(serializeRuntimeRpcResponse(successResponse(request.id, meta, result))) } catch (error) { - reply(JSON.stringify(this.mapError(request, meta, error))) + reply(serializeRuntimeRpcResponse(this.mapError(request, meta, error))) } return } @@ -161,7 +164,7 @@ export class RpcDispatcher { ) const response = successResponse(request.id, meta, result) response.streaming = true - reply(JSON.stringify(response)) + reply(serializeRuntimeRpcResponse(response)) } try { @@ -188,7 +191,7 @@ export class RpcDispatcher { request.params ) } catch (error) { - reply(JSON.stringify(this.mapError(request, meta, error))) + reply(serializeRuntimeRpcResponse(this.mapError(request, meta, error))) } } diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 0e4519656cb..babc255d5ed 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from 'vitest' +import { + MARKDOWN_DOCUMENT_LISTING_ERROR_CODE, + MarkdownDocumentListingCapacityError +} from '../../../shared/markdown-document-listing-limits' import { mapRuntimeError } from './errors' class LineageError extends Error { @@ -145,4 +149,19 @@ describe('mapRuntimeError', () => { _meta: { runtimeId: 'runtime-1' } }) }) + + it('preserves the Markdown listing capacity code across runtime RPC', () => { + expect( + mapRuntimeError( + 'req_1', + { runtimeId: 'runtime-1' }, + new MarkdownDocumentListingCapacityError() + ) + ).toMatchObject({ + ok: false, + error: { + code: MARKDOWN_DOCUMENT_LISTING_ERROR_CODE + } + }) + }) }) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 39190c6dfcb..fdd4f143d83 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -7,6 +7,7 @@ import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' import { LINEAR_ERROR_CODES } from '../../../shared/linear-agent-access' import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../shared/agent-session-host-authority' +import { MARKDOWN_DOCUMENT_LISTING_ERROR_CODE } from '../../../shared/markdown-document-listing-limits' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -60,7 +61,8 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ const COMPUTER_PASSTHROUGH_CODES: ReadonlySet = new Set(Object.values(COMPUTER_ERROR_CODES)) const LINEAR_PASSTHROUGH_CODES: ReadonlySet = new Set(LINEAR_ERROR_CODES) const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ - 'worktree_id_requires_full_path' + 'worktree_id_requires_full_path', + MARKDOWN_DOCUMENT_LISTING_ERROR_CODE ]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { diff --git a/src/main/runtime/rpc/methods/agent-session.test.ts b/src/main/runtime/rpc/methods/agent-session.test.ts index b3aeb044daf..a8fbbd06b30 100644 --- a/src/main/runtime/rpc/methods/agent-session.test.ts +++ b/src/main/runtime/rpc/methods/agent-session.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, - AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION @@ -49,9 +48,8 @@ describe('agent session RPC methods', () => { request('terminal.ensureAgentSession', { kind: 'explicit', worktree: 'id:worktree-1', - agent: 'omp', + agent: 'codex', providerSession: { key: 'session_id', id: 'provider-session-1' }, - ompResumeFilePath: '/custom/omp/project/session.jsonl', agentArgs: '--profile review', launchPreferences: { model: 'gpt-5', effort: 'high' }, presentation: 'focused', @@ -64,9 +62,8 @@ describe('agent session RPC methods', () => { { kind: 'explicit', worktree: 'id:worktree-1', - agent: 'omp', + agent: 'codex', providerSession: { key: 'session_id', id: 'provider-session-1' }, - ompResumeFilePath: '/custom/omp/project/session.jsonl', agentArgs: '--profile review', launchPreferences: { model: 'gpt-5', effort: 'high' }, presentation: 'focused', @@ -76,52 +73,6 @@ describe('agent session RPC methods', () => { ) }) - it.each([ - { - method: 'terminal.createAgentSession', - params: { - clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef', - worktree: 'id:worktree-1', - agent: 'codex', - presentation: 'focused' - }, - runtimeMethod: 'createAgentSession' as const - }, - { - method: 'terminal.ensureAgentSession', - params: { - kind: 'explicit', - worktree: 'id:worktree-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'provider-session-1' }, - presentation: 'focused' - }, - runtimeMethod: 'ensureAgentSession' as const - } - ])('keeps $method presentation viewer-local for paired clients', async (testCase) => { - for (const clientKind of ['runtime', 'mobile'] as const) { - const runtime = runtimeStub() - const dispatcher = new RpcDispatcher({ - runtime: runtime as unknown as OrcaRuntimeService, - methods: AGENT_SESSION_METHODS - }) - const replies: RpcResponse[] = [] - - await dispatcher.dispatchStreaming( - request(testCase.method, testCase.params), - (response) => replies.push(JSON.parse(response) as RpcResponse), - { pairedDeviceId: `paired-${clientKind}`, clientKind } - ) - - expect(replies).toHaveLength(1) - expect(replies[0]).toMatchObject({ ok: true }) - expect(runtime[testCase.runtimeMethod]).toHaveBeenCalledWith( - { ...testCase.params, presentation: 'background' }, - { clientId: `paired-${clientKind}`, clientKind } - ) - } - }) - it('keeps automatic authority checkpoint-only', async () => { const runtime = runtimeStub() const dispatcher = new RpcDispatcher({ @@ -370,6 +321,5 @@ describe('agent session RPC methods', () => { expect(RUNTIME_PROTOCOL_VERSION).toBe(3) expect(MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION).toBe(2) expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY) - expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY) }) }) diff --git a/src/main/runtime/rpc/methods/agent-session.ts b/src/main/runtime/rpc/methods/agent-session.ts index 08aaa91038e..18a1c26cca1 100644 --- a/src/main/runtime/rpc/methods/agent-session.ts +++ b/src/main/runtime/rpc/methods/agent-session.ts @@ -79,17 +79,6 @@ const AgentArgs = z ) .nullable() -const OmpResumeFilePath = z - .string() - .min(1) - .refine((value) => value === value.trim(), 'Invalid OMP resume path') - .refine( - (value) => - !hasUnsafeProviderSessionIdChars(value) && - Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, - 'Invalid OMP resume path' - ) - const ProviderSession = z .object({ key: z.enum(['session_id', 'conversation_id']), @@ -129,7 +118,6 @@ const ExplicitEnsure = z worktree: WorktreeSelector, agent: z.enum(RESUMABLE_TUI_AGENTS), providerSession: ProviderSession, - ompResumeFilePath: OmpResumeFilePath.optional(), agentArgs: AgentArgs.optional(), launchPreferences: LaunchPreferences.optional(), presentation: Presentation.optional(), @@ -137,14 +125,7 @@ const ExplicitEnsure = z }) .strict() .superRefine((value, context) => { - if (value.ompResumeFilePath !== undefined && value.agent !== 'omp') { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['ompResumeFilePath'], - message: 'OMP resume path requires the OMP agent' - }) - } - if (getAgentResumeArgv(value.agent, value.providerSession, value.ompResumeFilePath) === null) { + if (getAgentResumeArgv(value.agent, value.providerSession) === null) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['providerSession'], @@ -215,16 +196,6 @@ function callerContext( } } -function withExecutionHostAgentPresentation( - params: T, - clientKind: 'mobile' | 'runtime' | undefined -): T { - // Why: paired viewers focus their own mirror; the execution host may have no renderer. - return clientKind && params.presentation === 'focused' - ? { ...params, presentation: 'background' } - : params -} - function assertOperationTimestampWithinFutureSkew(clientOperationId: string): void { const timestamp = parseAgentSessionOperationTimestamp(clientOperationId) if (timestamp === null || timestamp > Date.now() + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS) { @@ -239,7 +210,7 @@ export const AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: EnsureAgentSessionParams, handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => (runtime as AgentSessionRuntime).ensureAgentSession( - withExecutionHostAgentPresentation(params, clientKind), + params, callerContext(pairedDeviceId ?? clientId, clientKind, signal) ) }), @@ -249,7 +220,7 @@ export const AGENT_SESSION_METHODS: RpcAnyMethod[] = [ handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => { assertOperationTimestampWithinFutureSkew(params.clientOperationId) return (runtime as AgentSessionRuntime).createAgentSession( - withExecutionHostAgentPresentation(params, clientKind), + params, callerContext(pairedDeviceId ?? clientId, clientKind, signal) ) } diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 74efa071a5f..64b0443260f 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -227,6 +227,7 @@ export const UiUpdate = z dismissedUpdateNudgeId: NullableString.optional(), notificationPermissionRequested: z.boolean().optional(), updateReassuranceSeen: z.boolean().optional(), + osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), browserDefaultUrl: NullableString.optional(), browserDefaultSearchEngine: z diff --git a/src/main/runtime/rpc/methods/clipboard-image-upload-buffer.ts b/src/main/runtime/rpc/methods/clipboard-image-upload-buffer.ts new file mode 100644 index 00000000000..eac0c809a33 --- /dev/null +++ b/src/main/runtime/rpc/methods/clipboard-image-upload-buffer.ts @@ -0,0 +1,121 @@ +const BASE64_PADDING_BYTE = '='.charCodeAt(0) + +function isBase64DataByte(byte: number): boolean { + return ( + (byte >= 65 && byte <= 90) || + (byte >= 97 && byte <= 122) || + (byte >= 48 && byte <= 57) || + byte === 43 || + byte === 47 + ) +} + +export class ClipboardImageUploadBuffer { + private readonly segments: Buffer[] = [] + private retainedLength = 0 + + constructor( + private readonly expectedLength: number, + private readonly segmentLength: number + ) {} + + get length(): number { + return this.retainedLength + } + + append(contentBase64: string): void { + if (contentBase64.length > this.expectedLength - this.retainedLength) { + throw new Error('Clipboard image upload exceeded expected size') + } + const source = Buffer.from(contentBase64, 'ascii') + const originalLength = this.retainedLength + const originalSegmentCount = this.segments.length + let sourceOffset = 0 + try { + while (sourceOffset < source.length) { + const segmentIndex = Math.floor(this.retainedLength / this.segmentLength) + const segmentOffset = this.retainedLength % this.segmentLength + let segment = this.segments[segmentIndex] + if (!segment) { + const segmentStart = segmentIndex * this.segmentLength + segment = Buffer.allocUnsafe( + Math.min(this.segmentLength, this.expectedLength - segmentStart) + ) + this.segments.push(segment) + } + const copyLength = Math.min(source.length - sourceOffset, segment.length - segmentOffset) + source.copy(segment, segmentOffset, sourceOffset, sourceOffset + copyLength) + sourceOffset += copyLength + this.retainedLength += copyLength + } + } catch (error) { + this.retainedLength = originalLength + this.segments.length = originalSegmentCount + throw error + } + } + + clear(): void { + this.retainedLength = 0 + this.segments.length = 0 + } + + decode(): Buffer { + const dataLength = this.validateAndGetDataLength() + const decoded = Buffer.allocUnsafe(Math.floor((dataLength * 3) / 4)) + let carry = '' + let remainingData = dataLength + let written = 0 + for (const segment of this.segments) { + if (remainingData === 0) { + break + } + const segmentDataLength = Math.min(segment.length, remainingData) + const data = segment.subarray(0, segmentDataLength).toString('ascii') + const combined = carry.length > 0 ? carry + data : data + const completeLength = combined.length - (combined.length % 4) + if (completeLength > 0) { + const complete = + completeLength === combined.length ? combined : combined.slice(0, completeLength) + written += decoded.write(complete, written, 'base64') + } + carry = completeLength === combined.length ? '' : combined.slice(completeLength) + remainingData -= segmentDataLength + } + if (carry.length > 0) { + written += decoded.write(carry, written, 'base64') + } + if (written !== decoded.length) { + throw new Error('Clipboard image content must be base64') + } + return decoded + } + + private validateAndGetDataLength(): number { + if (this.retainedLength % 4 === 1) { + throw new Error('Clipboard image content must be base64') + } + let dataLength = 0 + let paddingLength = 0 + let remaining = this.retainedLength + for (const segment of this.segments) { + const usedLength = Math.min(segment.length, remaining) + for (let index = 0; index < usedLength; index++) { + const byte = segment[index] + if (byte === BASE64_PADDING_BYTE) { + paddingLength += 1 + continue + } + if (paddingLength > 0 || !isBase64DataByte(byte)) { + throw new Error('Clipboard image content must be base64') + } + dataLength += 1 + } + remaining -= usedLength + } + if (paddingLength > 2) { + throw new Error('Clipboard image content must be base64') + } + return dataLength + } +} diff --git a/src/main/runtime/rpc/methods/clipboard.test.ts b/src/main/runtime/rpc/methods/clipboard.test.ts index 118b21c766a..a528239f000 100644 --- a/src/main/runtime/rpc/methods/clipboard.test.ts +++ b/src/main/runtime/rpc/methods/clipboard.test.ts @@ -4,6 +4,7 @@ import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image' @@ -18,7 +19,9 @@ vi.mock('../../../window/clipboard-image-temp-file', () => ({ import { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT, + CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS, CLIPBOARD_METHODS, + getRetainedClipboardImageUploadBase64CharsForTest, resetClipboardImageUploadsForTest } from './clipboard' @@ -100,7 +103,7 @@ describe('clipboard RPC methods', () => { it('accepts chunked uploads and forwards the recorded connectionId on commit', async () => { saveClipboardImageBufferAsTempFile.mockResolvedValue('/tmp/orca-paste-image.png') const dispatcher = makeDispatcher() - const contentBase64 = Buffer.from('png-bytes').toString('base64') + const contentBase64 = Buffer.from('png-byte').toString('base64') const start = await dispatcher.dispatch( makeRequest('clipboard.startImageUpload', { @@ -111,8 +114,8 @@ describe('clipboard RPC methods', () => { expect(start.ok).toBe(true) const uploadId = (start.ok ? start.result : null) as { uploadId: string } - const firstChunk = contentBase64.slice(0, 4) - const secondChunk = contentBase64.slice(4) + const firstChunk = contentBase64.slice(0, 2) + const secondChunk = contentBase64.slice(2) await expect( dispatcher.dispatch( makeRequest('clipboard.appendImageUploadChunk', { @@ -121,7 +124,7 @@ describe('clipboard RPC methods', () => { contentBase64: firstChunk }) ) - ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: 4 } }) + ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: 2 } }) await expect( dispatcher.dispatch( makeRequest('clipboard.appendImageUploadChunk', { @@ -131,13 +134,15 @@ describe('clipboard RPC methods', () => { }) ) ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: contentBase64.length } }) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(contentBase64.length) await expect( dispatcher.dispatch( makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) ) ).resolves.toMatchObject({ ok: true, result: '/tmp/orca-paste-image.png' }) - expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-bytes'), { + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) + expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-byte'), { connectionId: 'ssh-1' }) }) @@ -161,6 +166,7 @@ describe('clipboard RPC methods', () => { ) expect(response.ok).toBe(false) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() }) @@ -183,6 +189,7 @@ describe('clipboard RPC methods', () => { }) ) ).resolves.toMatchObject({ ok: false }) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) await expect( dispatcher.dispatch( makeRequest('clipboard.appendImageUploadChunk', { @@ -258,6 +265,7 @@ describe('clipboard RPC methods', () => { ) expect(response.ok).toBe(false) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() }) @@ -290,6 +298,7 @@ describe('clipboard RPC methods', () => { ) expect(response.ok).toBe(false) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() }) @@ -302,12 +311,21 @@ describe('clipboard RPC methods', () => { }) ) const uploadId = (start.ok ? start.result : null) as { uploadId: string } + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'AAAA' + }) + ) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(4) await expect( dispatcher.dispatch( makeRequest('clipboard.abortImageUpload', { uploadId: uploadId.uploadId }) ) ).resolves.toMatchObject({ ok: true, result: { aborted: true } }) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) await expect( dispatcher.dispatch( makeRequest('clipboard.abortImageUpload', { uploadId: uploadId.uploadId }) @@ -349,22 +367,137 @@ describe('clipboard RPC methods', () => { makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) ) ).resolves.toMatchObject({ ok: false }) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledTimes(1) }) + it('caps retained chunks process-wide and keeps commit memory charged until save completes', async () => { + let releaseSave!: () => void + const saveGate = new Promise((resolve) => { + releaseSave = resolve + }) + saveClipboardImageBufferAsTempFile.mockImplementation(async (content: Buffer) => { + expect(content).toHaveLength(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES) + await saveGate + return '/tmp/orca-paste-image.png' + }) + const dispatcher = makeDispatcher() + const firstStart = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS, + connectionId: 'ssh-1' + }) + ) + const secondStart = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: 'ssh-2' + }) + ) + const firstUpload = (firstStart.ok ? firstStart.result : null) as { uploadId: string } + const secondUpload = (secondStart.ok ? secondStart.result : null) as { uploadId: string } + const fullChunk = 'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS) + + for ( + let offset = 0; + offset < CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS; + offset += fullChunk.length + ) { + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: firstUpload.uploadId, + offset, + contentBase64: fullChunk + }) + ) + ).resolves.toMatchObject({ ok: true }) + } + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe( + CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS + ) + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: secondUpload.uploadId, + offset: 0, + contentBase64: 'AAAA' + }) + ) + ).resolves.toMatchObject({ ok: false }) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe( + CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS + ) + + const commit = dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: firstUpload.uploadId }) + ) + await vi.waitFor(() => expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledTimes(1)) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe( + CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS + ) + releaseSave() + await expect(commit).resolves.toMatchObject({ ok: true }) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: secondUpload.uploadId, + offset: 0, + contentBase64: 'AAAA' + }) + ) + ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: 4 } }) + }) + + it('releases retained chunks when test state is reset', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'AAAA' + }) + ) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(4) + + resetClipboardImageUploadsForTest() + + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) + }) + it('bounds concurrent uploads and releases slots through TTL cleanup', async () => { vi.useFakeTimers() const dispatcher = makeDispatcher() for (let index = 0; index < CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT; index++) { - await expect( - dispatcher.dispatch( - makeRequest('clipboard.startImageUpload', { - expectedBase64Length: 4, - connectionId: null - }) - ) - ).resolves.toMatchObject({ ok: true }) + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + expect(start).toMatchObject({ ok: true }) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'AAAA' + }) + ) } + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe( + CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT * 4 + ) await expect( dispatcher.dispatch( makeRequest('clipboard.startImageUpload', { @@ -375,6 +508,7 @@ describe('clipboard RPC methods', () => { ).resolves.toMatchObject({ ok: false }) vi.advanceTimersByTime(5 * 60 * 1000 + 1) + expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) await expect( dispatcher.dispatch( diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index 3d5212c7a52..72476f677e6 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -6,23 +6,28 @@ import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image' +import { ClipboardImageUploadBuffer } from './clipboard-image-upload-buffer' const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8 +export const CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000 +const CLIPBOARD_IMAGE_UPLOAD_MEMORY_ERROR = 'Too much clipboard image upload data is in progress' const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ type ClipboardImageUpload = { expectedBase64Length: number connectionId?: string | null - chunks: string[] - receivedBase64Length: number + content: ClipboardImageUploadBuffer + reservedBase64Length: number expiresAt: number ttlTimer: ReturnType + committing: boolean } const clipboardImageUploads = new Map() +let retainedClipboardImageUploadBase64Chars = 0 function isValidBase64(value: string): boolean { return value.length % 4 !== 1 && BASE64_PATTERN.test(value) @@ -38,7 +43,7 @@ function pruneExpiredUploads(now = Date.now()): void { function scheduleUploadExpiry(uploadId: string): ReturnType { const timer = setTimeout(() => { - clipboardImageUploads.delete(uploadId) + deleteUpload(uploadId) }, CLIPBOARD_IMAGE_UPLOAD_TTL_MS) if (typeof timer === 'object' && 'unref' in timer) { timer.unref() @@ -52,12 +57,26 @@ function refreshUploadExpiry(uploadId: string, upload: ClipboardImageUpload): vo upload.ttlTimer = scheduleUploadExpiry(uploadId) } +function releaseUploadRetention(upload: ClipboardImageUpload): void { + retainedClipboardImageUploadBase64Chars -= upload.reservedBase64Length + upload.reservedBase64Length = 0 + upload.content.clear() +} + +function finishUpload(uploadId: string, upload: ClipboardImageUpload): void { + clearTimeout(upload.ttlTimer) + if (clipboardImageUploads.get(uploadId) === upload) { + clipboardImageUploads.delete(uploadId) + } + releaseUploadRetention(upload) +} + function deleteUpload(uploadId: string): void { const upload = clipboardImageUploads.get(uploadId) - if (upload) { - clearTimeout(upload.ttlTimer) + if (!upload || upload.committing) { + return } - clipboardImageUploads.delete(uploadId) + finishUpload(uploadId, upload) } function getUpload(uploadId: string): ClipboardImageUpload { @@ -66,13 +85,21 @@ function getUpload(uploadId: string): ClipboardImageUpload { if (!upload) { throw new Error('Clipboard image upload was not found') } + if (upload.committing) { + throw new Error('Clipboard image upload is already committing') + } return upload } -function assertValidBase64Content(value: string): void { - if (!isValidBase64(value)) { - throw new Error('Clipboard image content must be base64') +function reserveUploadBase64(chars: number): boolean { + if ( + chars > + CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS - retainedClipboardImageUploadBase64Chars + ) { + return false } + retainedClipboardImageUploadBase64Chars += chars + return true } function clipboardImageBase64Payload(maxChars: number, tooLargeMessage: string) { @@ -148,10 +175,14 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ clipboardImageUploads.set(uploadId, { expectedBase64Length: params.expectedBase64Length, connectionId: params.connectionId, - chunks: [], - receivedBase64Length: 0, + content: new ClipboardImageUploadBuffer( + params.expectedBase64Length, + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + ), + reservedBase64Length: 0, expiresAt: Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS, - ttlTimer: scheduleUploadExpiry(uploadId) + ttlTimer: scheduleUploadExpiry(uploadId), + committing: false }) return { uploadId } } @@ -161,17 +192,25 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ params: AppendImageUploadChunk, handler: (params) => { const upload = getUpload(params.uploadId) - if (params.offset !== upload.receivedBase64Length) { + if (params.offset !== upload.content.length) { throw new Error('Clipboard image chunk offset is out of order') } - const nextLength = upload.receivedBase64Length + params.contentBase64.length + const nextLength = upload.content.length + params.contentBase64.length if (nextLength > upload.expectedBase64Length) { throw new Error('Clipboard image upload exceeded expected size') } - upload.chunks.push(params.contentBase64) - upload.receivedBase64Length = nextLength + if (!reserveUploadBase64(params.contentBase64.length)) { + throw new Error(CLIPBOARD_IMAGE_UPLOAD_MEMORY_ERROR) + } + try { + upload.content.append(params.contentBase64) + } catch (error) { + retainedClipboardImageUploadBase64Chars -= params.contentBase64.length + throw error + } + upload.reservedBase64Length += params.contentBase64.length refreshUploadExpiry(params.uploadId, upload) - return { receivedBase64Length: upload.receivedBase64Length } + return { receivedBase64Length: upload.content.length } } }), defineMethod({ @@ -179,19 +218,19 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ params: CommitImageUpload, handler: async (params) => { const upload = getUpload(params.uploadId) + upload.committing = true + clearTimeout(upload.ttlTimer) try { - if (upload.receivedBase64Length !== upload.expectedBase64Length) { + if (upload.content.length !== upload.expectedBase64Length) { throw new Error('Clipboard image upload is incomplete') } - const contentBase64 = upload.chunks.join('') - assertValidBase64Content(contentBase64) - return await saveClipboardImageBufferAsTempFile(Buffer.from(contentBase64, 'base64'), { + const content = upload.content.decode() + upload.content.clear() + return await saveClipboardImageBufferAsTempFile(content, { connectionId: upload.connectionId }) } finally { - // Why: failed SSH or filesystem commits must not leave bounded upload - // memory pinned until TTL cleanup. - deleteUpload(params.uploadId) + finishUpload(params.uploadId, upload) } } }), @@ -206,7 +245,12 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ ] export function resetClipboardImageUploadsForTest(): void { - for (const uploadId of clipboardImageUploads.keys()) { - deleteUpload(uploadId) + for (const [uploadId, upload] of clipboardImageUploads) { + finishUpload(uploadId, upload) } + retainedClipboardImageUploadBase64Chars = 0 +} + +export function getRetainedClipboardImageUploadBase64CharsForTest(): number { + return retainedClipboardImageUploadBase64Chars } diff --git a/src/main/runtime/rpc/methods/orchestration-gates.ts b/src/main/runtime/rpc/methods/orchestration-gates.ts index ca16b9719f0..0b6853fc746 100644 --- a/src/main/runtime/rpc/methods/orchestration-gates.ts +++ b/src/main/runtime/rpc/methods/orchestration-gates.ts @@ -3,6 +3,10 @@ import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import type { GateStatus } from '../../orchestration/db' import { Coordinator } from '../../orchestration/coordinator' +import { + assertOrchestrationStringListFits, + assertOrchestrationWriteFits +} from '../../orchestration/query-retention' // Why: the coordinator instance is stored at module scope so orchestration.runStop // can signal it to halt. Only one coordinator can run at a time (enforced by @@ -108,10 +112,12 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [ let options: string[] | undefined if (params.options) { try { + assertOrchestrationWriteFits('Decision gate options', [params.options]) const parsed = JSON.parse(params.options) if (!Array.isArray(parsed) || !parsed.every((option) => typeof option === 'string')) { throw new Error('not an array of strings') } + assertOrchestrationStringListFits('Decision gate options', parsed) options = parsed } catch { throw new Error('Invalid --options: must be a JSON array of strings') @@ -144,11 +150,17 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [ params: GateListParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() - const gates = db.listGates({ + const filter = { taskId: params.task, status: params.status as GateStatus - }) - return { gates, count: gates.length } + } + const gates = db.listGates(filter) + const total = db.countGates(filter) + return { + gates, + count: gates.length, + ...(total > gates.length ? { total, truncated: true as const } : {}) + } } }) ] diff --git a/src/main/runtime/rpc/methods/orchestration-query-retention.test.ts b/src/main/runtime/rpc/methods/orchestration-query-retention.test.ts new file mode 100644 index 00000000000..56f6c4fc9c7 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-query-retention.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type Database from '../../../sqlite/sync-database' +import { OrchestrationDb } from '../../orchestration/db' +import { + ORCHESTRATION_QUERY_MAX_ROWS, + ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES, + ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES +} from '../../orchestration/query-retention' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' +import { ORCHESTRATION_METHODS } from './orchestration' + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +describe('orchestration RPC query retention', () => { + let db: OrchestrationDb | undefined + + afterEach(() => { + db?.close() + }) + + async function call(name: string, params: Record): Promise { + db ??= new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + return callWithRuntime(runtime, name, params) + } + + async function callWithRuntime( + runtime: OrcaRuntimeService, + name: string, + params: Record + ): Promise { + const ctx: RpcContext = { runtime } + const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + function createDb(): OrchestrationDb { + db = new OrchestrationDb(':memory:') + return db + } + + it('reports remaining unread messages while successive checks drain the queue', async () => { + const d = createDb() + for (let index = 0; index < ORCHESTRATION_QUERY_MAX_ROWS + 1; index += 1) { + d.insertMessage({ from: 'sender', to: 'worker', subject: `message-${index}` }) + } + + const first = (await call('orchestration.check', { terminal: 'worker' })) as { + count: number + truncated?: boolean + remaining?: number + } + expect(first).toMatchObject({ + count: ORCHESTRATION_QUERY_MAX_ROWS, + truncated: true, + remaining: 1 + }) + + const second = (await call('orchestration.check', { terminal: 'worker' })) as { + count: number + truncated?: boolean + } + expect(second).toMatchObject({ count: 1 }) + expect(second.truncated).toBeUndefined() + }) + + it('returns truncation instead of waiting forever on an oversized legacy row', async () => { + const d = createDb() + sqliteFor(d) + .prepare( + `INSERT INTO messages (id, from_handle, to_handle, subject, body) + VALUES ('msg_oversized', 'sender', 'worker', 'oversized', ?)` + ) + .run('x'.repeat(ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES + 1)) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(d) + const waitForMessage = vi.spyOn(runtime, 'waitForMessage') + + const result = await callWithRuntime(runtime, 'orchestration.check', { + terminal: 'worker', + wait: true, + timeoutMs: 60_000 + }) + + expect(result).toMatchObject({ + count: 0, + truncated: true, + remaining: 1 + }) + expect(waitForMessage).not.toHaveBeenCalled() + }) + + it('rejects oversized waiting type filters before retaining the request', async () => { + const d = createDb() + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(d) + const waitForMessage = vi.spyOn(runtime, 'waitForMessage') + const types = 'status,'.repeat( + Math.ceil(ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES / 'status,'.length) + 1 + ) + + await expect( + callWithRuntime(runtime, 'orchestration.check', { + terminal: 'worker', + wait: true, + types + }) + ).rejects.toThrow( + `${ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES}-byte orchestration wait limit` + ) + expect(waitForMessage).not.toHaveBeenCalled() + }) + + it('preserves normal waiting type filters while removing duplicate retention', async () => { + const d = createDb() + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(d) + const waitForMessage = vi.spyOn(runtime, 'waitForMessage').mockResolvedValue() + + await expect( + callWithRuntime(runtime, 'orchestration.check', { + terminal: 'worker', + wait: true, + timeoutMs: 100, + types: 'status,status,worker_done,escalation,decision_gate' + }) + ).resolves.toMatchObject({ count: 0, messages: [] }) + expect(waitForMessage).toHaveBeenCalledWith('worker', { + typeFilter: ['status', 'worker_done', 'escalation', 'decision_gate'], + timeoutMs: 100, + signal: undefined + }) + }) + + it('reports exact totals when task and gate lists are capped', async () => { + const d = createDb() + const task = d.createTask({ spec: 'gated' }) + for (let index = 0; index < ORCHESTRATION_QUERY_MAX_ROWS + 1; index += 1) { + d.createTask({ spec: `task-${index}` }) + d.createGate({ taskId: task.id, question: `question-${index}` }) + } + + const tasks = (await call('orchestration.taskList', {})) as { + count: number + total?: number + truncated?: boolean + } + expect(tasks).toMatchObject({ + count: ORCHESTRATION_QUERY_MAX_ROWS, + total: ORCHESTRATION_QUERY_MAX_ROWS + 2, + truncated: true + }) + + const gates = (await call('orchestration.gateList', {})) as { + count: number + total?: number + truncated?: boolean + } + expect(gates).toMatchObject({ + count: ORCHESTRATION_QUERY_MAX_ROWS, + total: ORCHESTRATION_QUERY_MAX_ROWS + 1, + truncated: true + }) + }) + + it('reports the exact inbox total when a requested page omits rows', async () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + d.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + + await expect(call('orchestration.inbox', { limit: 1 })).resolves.toMatchObject({ + count: 1, + total: 2, + truncated: true + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 7a731f91774..81dcf046121 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -2,12 +2,27 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' -import type { MessageType, MessagePriority, TaskStatus } from '../../orchestration/db' +import type { + MessageRow, + MessageType, + MessagePriority, + TaskStatus +} from '../../orchestration/db' import { buildDispatchPreamble } from '../../orchestration/preamble' import { formatMessageBanner } from '../../orchestration/formatter' import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { + assertOrchestrationStringListFits, + assertOrchestrationWaitTypeFilterFits, + assertOrchestrationWriteFits +} from '../../orchestration/query-retention' import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' +import { + JsonStringifyByteLimitError, + stringifyJsonWithinByteLimit +} from '../../../../shared/memory-safety/node-bounded-json-stringify' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../../shared/remote-runtime-memory-limits' import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' const MESSAGE_TYPES: MessageType[] = [ @@ -21,6 +36,21 @@ const MESSAGE_TYPES: MessageType[] = [ 'heartbeat' ] +function parseMessageTypeFilter(types: string | undefined): MessageType[] | undefined { + if (!types) { + return undefined + } + const parsed = types + .split(',') + .map((type) => type.trim()) + .filter(Boolean) as MessageType[] + const invalidTypes = parsed.filter((type) => !MESSAGE_TYPES.includes(type)) + if (invalidTypes.length > 0) { + throw new Error(`Invalid --types: ${invalidTypes.join(',')}`) + } + return Array.from(new Set(parsed)) +} + const TASK_STATUSES: TaskStatus[] = [ 'pending', 'ready', @@ -30,6 +60,19 @@ const TASK_STATUSES: TaskStatus[] = [ 'blocked' ] +const ASK_DEFAULT_TIMEOUT_MS = 600_000 + +// Why: ask pins a shared long-poll slot for its entire timeout, so a caller-supplied +// value can't be unbounded; 30 min covers a slow human gate and still frees the slot. +const ASK_MAX_TIMEOUT_MS = 1_800_000 + +export function clampAskTimeoutMs(timeoutMs: number | undefined): number { + if (timeoutMs === undefined) { + return ASK_DEFAULT_TIMEOUT_MS + } + return Math.min(Math.max(0, timeoutMs), ASK_MAX_TIMEOUT_MS) +} + function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.` } @@ -269,54 +312,113 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ defineMethod({ name: 'orchestration.check', params: CheckParams, - handler: async (params, { runtime, signal }) => { + handler: async (params, { runtime, signal, requestId }) => { const db = runtime.getOrchestrationDb() const handle = params.terminal ?? 'unknown' - const typeFilter = params.types - ? (params.types - .split(',') - .map((t) => t.trim()) - .filter(Boolean) as MessageType[]) - : undefined - const invalidTypes = typeFilter?.filter((t) => !MESSAGE_TYPES.includes(t)) - if (invalidTypes && invalidTypes.length > 0) { - throw new Error(`Invalid --types: ${invalidTypes.join(',')}`) + if (params.wait) { + assertOrchestrationWaitTypeFilterFits(params.types) } + assertOrchestrationWriteFits('Message type filter', [params.types]) + const typeFilter = parseMessageTypeFilter(params.types) // 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 responseFits = (result: unknown): boolean => { + try { + // Why: one-shot handlers omit requestId, so reserve their 1 MiB request frame for the correlated response ID. + const byteLimit = + requestId === undefined + ? REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES - 1024 * 1024 + : REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + stringifyJsonWithinByteLimit( + { + id: requestId ?? '', + ok: true, + result, + _meta: { runtimeId: runtime.getRuntimeId() } + }, + byteLimit + ) + return true + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + return false + } + throw error + } + } + + const buildResult = (visibleMessages: MessageRow[], remaining: number) => { + const saturation = remaining > 0 ? { truncated: true as const, remaining } : {} + if (params.inject) { + const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') + return { + messages: visibleMessages, + formatted, + count: visibleMessages.length, + ...saturation + } + } + return { messages: visibleMessages, count: visibleMessages.length, ...saturation } + } + + const fittingPrefixLength = (messages: MessageRow[], total: number): number => { + let low = 0 + let high = messages.length + while (low < high) { + const middle = Math.ceil((low + high) / 2) + const remaining = Math.max(0, total - middle) + if (responseFits(buildResult(messages.slice(0, middle), remaining))) { + low = middle + } else { + high = middle - 1 + } + } + return low + } + const readAndReturn = () => { + const totalBeforeRead = showAll + ? db.countAllMessagesForHandle(handle, typeFilter) + : db.countUnreadMessages(handle, typeFilter) const messages = showAll ? db.getAllMessagesForHandle(handle, undefined, typeFilter) : db.getUnreadMessages(handle, typeFilter) + let deliveredMessages = messages.slice( + 0, + fittingPrefixLength(messages, totalBeforeRead) + ) - let visibleMessages = messages - if (consumeUnread && messages.length > 0) { + let visibleMessages = deliveredMessages + if (consumeUnread && deliveredMessages.length > 0) { // Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too. - visibleMessages = messages.map((message) => { + visibleMessages = deliveredMessages.map((message) => { const reconciled = reconcileLifecycleMessage(db, message) return reconciled.action === 'rejected' ? (db.getMessageById(message.id) ?? message) : message }) - db.markAsRead(messages.map((m) => m.id)) + const visibleLength = fittingPrefixLength(visibleMessages, totalBeforeRead) + visibleMessages = visibleMessages.slice(0, visibleLength) + deliveredMessages = deliveredMessages.slice(0, visibleLength) } - if (params.inject) { - const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') - return { messages: visibleMessages, formatted, count: visibleMessages.length } + const remainingUpperBound = Math.max(0, totalBeforeRead - visibleMessages.length) + const result = buildResult(visibleMessages, remainingUpperBound) + if (consumeUnread && deliveredMessages.length > 0) { + db.markAsRead(deliveredMessages.map((message) => message.id)) + return buildResult(visibleMessages, db.countUnreadMessages(handle, typeFilter)) } - - return { messages: visibleMessages, count: visibleMessages.length } + return result } if (signal?.aborted) { return { messages: [], count: 0 } } const result = readAndReturn() - if (result.count > 0 || !params.wait) { + if (result.count > 0 || result.truncated || !params.wait) { return result } @@ -367,7 +469,14 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const messages = params.terminal ? db.getAllMessagesForHandle(params.terminal, params.limit) : db.getInbox(params.limit) - return { messages, count: messages.length } + const total = params.terminal + ? db.countAllMessagesForHandle(params.terminal) + : db.countInbox() + return { + messages, + count: messages.length, + ...(total > messages.length ? { total, truncated: true as const } : {}) + } } }), @@ -379,10 +488,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ let deps: string[] | undefined if (params.deps) { try { + assertOrchestrationWriteFits('Task dependencies', [params.deps]) const parsed = JSON.parse(params.deps) if (!Array.isArray(parsed) || !parsed.every((d) => typeof d === 'string')) { throw new Error('not an array of strings') } + assertOrchestrationStringListFits('Task dependencies', parsed) deps = parsed } catch { throw new Error('Invalid --deps: must be a JSON array of task IDs') @@ -405,11 +516,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ params: TaskListParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() - // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. - const joined = db.listTasksWithDispatch({ + const filter = { status: params.status as TaskStatus, ready: params.ready - }) + } + // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. + const joined = db.listTasksWithDispatch(filter) const tasks = joined.map((row) => { const { assignee_handle, dispatch_id, ...base } = row if (base.status === 'dispatched') { @@ -417,9 +529,11 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } return base }) + const total = db.countTasks(filter) return { tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, - count: tasks.length + count: tasks.length, + ...(total > tasks.length ? { total, truncated: true as const } : {}) } } }), @@ -567,12 +681,15 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const db = runtime.getOrchestrationDb() const from = params.from ?? 'unknown' - const timeoutMs = params.timeoutMs ?? 600_000 + // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. + const timeoutMs = clampAskTimeoutMs(params.timeoutMs) + assertOrchestrationWriteFits('Decision gate options', [params.options]) const options = params.options ?.split(',') .map((s) => s.trim()) .filter(Boolean) ?? [] + assertOrchestrationStringListFits('Decision gate options', options) const payload = JSON.stringify({ question: params.question, options }) const outbound = db.insertMessage({ @@ -600,15 +717,16 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ answer: reply.body, messageId: reply.id, threadId, - timedOut: false + timedOut: false, + timeoutMs } } if (signal?.aborted) { - return { answer: null, messageId: null, threadId, timedOut: true } + return { answer: null, messageId: null, threadId, timedOut: true, timeoutMs } } const remainingMs = deadline - Date.now() if (remainingMs <= 0) { - return { answer: null, messageId: null, threadId, timedOut: true } + return { answer: null, messageId: null, threadId, timedOut: true, timeoutMs } } // Why: signal releases the waiter on client disconnect while the already-sent decision gate stays visible to the recipient. await runtime.waitForMessage(from, { timeoutMs: remainingMs, signal }) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 1261a2de4cf..d1847e690c9 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -53,6 +53,7 @@ import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../../shared/terminal-multiplex-flow-control' import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin' +import { appendCompactedStringChunk } from '../../../../shared/string-chunk-compaction' const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 const TERMINAL_OUTPUT_FLUSH_MS = 5 @@ -208,7 +209,7 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp flush() pendingCwd = meta.cwd } - chunks.push(data) + appendCompactedStringChunk(chunks, data) pendingRawLength += rawLength const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) const measurement = measureTerminalStreamByteLength(data, { @@ -935,12 +936,7 @@ const TerminalCreateParams = z.object({ .object({ agentCommand: z.string().optional(), agentArgs: z.string(), - agentEnv: z.record(z.string(), z.string()), - ompResumeFilePath: z - .string() - .min(1) - .max(32 * 1024) - .optional() + agentEnv: z.record(z.string(), z.string()) }) .optional(), resumeProviderSession: z diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 5bac18bf90f..45848c92db9 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -128,9 +128,8 @@ export const WorktreeCreate = z ) .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) .optional(), - // Why: some clients (e.g. desktop) pass a pre-built launch command so the - // first terminal pane launches the selected agent instead of an idle shell. - // Clients that can't quote for the host shell send `startupAgent` instead. + // Why: mobile clients pass a startup command (e.g. 'claude') so the first + // terminal pane launches the selected agent instead of an idle shell. startupCommand: OptionalString, startupEnv: z.record(z.string(), z.string()).optional(), startupLaunchConfig: sleepingAgentLaunchConfigSchema, diff --git a/src/main/runtime/rpc/runtime-rpc-response-serialization.ts b/src/main/runtime/rpc/runtime-rpc-response-serialization.ts new file mode 100644 index 00000000000..1fccf192a38 --- /dev/null +++ b/src/main/runtime/rpc/runtime-rpc-response-serialization.ts @@ -0,0 +1,45 @@ +import { + JsonStringifyByteLimitError, + stringifyJsonWithinByteLimit +} from '../../../shared/memory-safety/node-bounded-json-stringify' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' +import type { RpcResponse } from './core' +import { errorResponse } from './errors' + +const RESPONSE_TOO_LARGE_MESSAGE = `RPC response exceeds ${REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES} bytes` + +export function boundRuntimeRpcResponse(response: RpcResponse): RpcResponse { + try { + serializeWithinLimit(response) + return response + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + return responseTooLargeError(response) + } + throw error + } +} + +export function serializeRuntimeRpcResponse(response: RpcResponse): string { + try { + return serializeWithinLimit(response) + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + return serializeWithinLimit(responseTooLargeError(response)) + } + throw error + } +} + +function serializeWithinLimit(response: RpcResponse): string { + return stringifyJsonWithinByteLimit(response, REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES).serialized +} + +function responseTooLargeError(response: RpcResponse): RpcResponse { + return errorResponse( + response.id, + response._meta, + 'response_too_large', + RESPONSE_TOO_LARGE_MESSAGE + ) +} diff --git a/src/main/runtime/rpc/unix-socket-transport.test.ts b/src/main/runtime/rpc/unix-socket-transport.test.ts index e321c2c59e9..8ed4d518075 100644 --- a/src/main/runtime/rpc/unix-socket-transport.test.ts +++ b/src/main/runtime/rpc/unix-socket-transport.test.ts @@ -63,7 +63,7 @@ describe('UnixSocketTransport', () => { ;(transport as unknown as UnixSocketTransportInternals).handleConnection( socket as unknown as Socket ) - socket.emit('data', '{"id":"pending","method":"wait"}\n') + socket.emit('data', Buffer.from('{"id":"pending","method":"wait"}\n')) vi.advanceTimersByTime(100) expect(socket.writes).toHaveLength(1) @@ -74,4 +74,26 @@ describe('UnixSocketTransport', () => { vi.advanceTimersByTime(500) expect(socket.writes).toHaveLength(1) }) + + it('parses a request delivered as 100,000 one-byte fragments', () => { + const transport = new UnixSocketTransport({ + endpoint: '/tmp/orca-runtime-rpc-test.sock', + kind: 'unix' + }) + const socket = new FakeSocket() + let received = '' + transport.onMessage((message) => { + received = message + }) + ;(transport as unknown as UnixSocketTransportInternals).handleConnection( + socket as unknown as Socket + ) + + const request = Buffer.from(`${' '.repeat(99_960)}{"id":"tiny","method":"status"}\n`) + for (let index = 0; index < request.byteLength; index += 1) { + socket.emit('data', request.subarray(index, index + 1)) + } + + expect(received).toBe('{"id":"tiny","method":"status"}') + }) }) diff --git a/src/main/runtime/rpc/unix-socket-transport.ts b/src/main/runtime/rpc/unix-socket-transport.ts index ed66f94d907..24b53350bc6 100644 --- a/src/main/runtime/rpc/unix-socket-transport.ts +++ b/src/main/runtime/rpc/unix-socket-transport.ts @@ -6,6 +6,7 @@ // away. See design doc §3.1. import { createServer, type Server, type Socket } from 'node:net' import { chmodSync, existsSync, rmSync } from 'node:fs' +import { GrowingByteBuffer } from '../../../shared/growing-byte-buffer' import type { RpcMessageContext, RpcTransport } from './transport' const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024 @@ -104,7 +105,7 @@ export class UnixSocketTransport implements RpcTransport { private handleConnection(socket: Socket): void { this.activeSockets.add(socket) - let buffer = '' + const buffer = new GrowingByteBuffer() let oversized = false // Why: each in-flight dispatch registers its own AbortController here so // `socket.on('close')` can abort them all at once. Keeping the set scoped @@ -114,7 +115,6 @@ export class UnixSocketTransport implements RpcTransport { // multiplexes sequential requests. const inflight = new Set<() => void>() - socket.setEncoding('utf8') socket.setNoDelay(true) socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => { socket.destroy() @@ -129,30 +129,31 @@ export class UnixSocketTransport implements RpcTransport { inflight.clear() this.activeSockets.delete(socket) }) - socket.on('data', (chunk: string) => { + socket.on('data', (chunk: Buffer) => { if (oversized) { return } - buffer += chunk + buffer.append(chunk) // Why: the Orca runtime lives in Electron main, so it must reject // oversized local RPC frames instead of letting a local client grow an // unbounded buffer and stall the app. - if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) { + if (buffer.byteLength > MAX_RUNTIME_RPC_MESSAGE_BYTES) { oversized = true + buffer.clear() this.messageHandler?.('', (response) => { socket.write(`${response}\n`) socket.end() }) return } - let newlineIndex = buffer.indexOf('\n') + let newlineIndex = buffer.indexOfByte(0x0a) while (newlineIndex !== -1) { - const rawMessage = buffer.slice(0, newlineIndex).trim() - buffer = buffer.slice(newlineIndex + 1) + const rawMessage = buffer.takePrefixString(newlineIndex).trim() + buffer.discardPrefix(1) if (rawMessage) { this.dispatchMessage(socket, rawMessage, inflight) } - newlineIndex = buffer.indexOf('\n') + newlineIndex = buffer.indexOfByte(0x0a) } }) } diff --git a/src/main/runtime/rpc/ws-fallback-port-store.test.ts b/src/main/runtime/rpc/ws-fallback-port-store.test.ts index 2edd314c212..2c3ae73381a 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.test.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.test.ts @@ -1,8 +1,12 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { readWsFallbackPort, writeWsFallbackPort } from './ws-fallback-port-store' +import { + MAX_FALLBACK_PORT_FILE_BYTES, + readWsFallbackPort, + writeWsFallbackPort +} from './ws-fallback-port-store' function makeUserDataPath(): string { return mkdtempSync(join(tmpdir(), 'ws-fallback-port-test-')) @@ -32,4 +36,13 @@ describe('ws-fallback-port-store', () => { writeWsFallbackPort(userDataPath, 70000) expect(readWsFallbackPort(userDataPath)).toBeUndefined() }) + + it('ignores an oversized sparse fallback-port file', () => { + const userDataPath = makeUserDataPath() + const path = join(userDataPath, 'mobile-ws-fallback-port.json') + writeFileSync(path, '{"port":54321}', 'utf8') + truncateSync(path, MAX_FALLBACK_PORT_FILE_BYTES + 1) + + expect(readWsFallbackPort(userDataPath)).toBeUndefined() + }) }) diff --git a/src/main/runtime/rpc/ws-fallback-port-store.ts b/src/main/runtime/rpc/ws-fallback-port-store.ts index beedcea8cf2..dcfaf2eeb95 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.ts @@ -1,5 +1,6 @@ -import { readFileSync, writeFileSync } from 'node:fs' +import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { readNodeFileSyncWithinLimit } from '../../../shared/memory-safety/node-bounded-file-reader' // Why: when the preferred WS port is taken (second Orca instance), the OS // assigns a random port. Paired mobile devices store ws://ip:port endpoints, @@ -10,6 +11,7 @@ import { join } from 'node:path' // again. const FALLBACK_PORT_FILE = 'mobile-ws-fallback-port.json' +export const MAX_FALLBACK_PORT_FILE_BYTES = 1024 function isValidPort(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535 @@ -17,7 +19,10 @@ function isValidPort(value: unknown): value is number { export function readWsFallbackPort(userDataPath: string): number | undefined { try { - const raw = readFileSync(join(userDataPath, FALLBACK_PORT_FILE), 'utf8') + const raw = readNodeFileSyncWithinLimit( + join(userDataPath, FALLBACK_PORT_FILE), + MAX_FALLBACK_PORT_FILE_BYTES + ).buffer.toString('utf8') const parsed: unknown = JSON.parse(raw) if ( typeof parsed === 'object' && diff --git a/src/main/runtime/runtime-file-watcher-admission.test.ts b/src/main/runtime/runtime-file-watcher-admission.test.ts new file mode 100644 index 00000000000..ecb5752c84d --- /dev/null +++ b/src/main/runtime/runtime-file-watcher-admission.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + RUNTIME_FILE_WATCHER_MAX_LEASES, + RUNTIME_FILE_WATCHER_MAX_PATH_BYTES, + RuntimeFileWatcherAdmission +} from './runtime-file-watcher-admission' + +describe('runtime file watcher admission', () => { + it('caps pending and active leases, then recovers after release', () => { + const admission = new RuntimeFileWatcherAdmission() + const releases = Array.from({ length: RUNTIME_FILE_WATCHER_MAX_LEASES }, (_, index) => + admission.claim('runtime-a', index % 2 === 0 ? 'ssh-a' : undefined, `/repo-${index}`) + ) + + expect(admission.evidence().leases).toBe(RUNTIME_FILE_WATCHER_MAX_LEASES) + expect(() => admission.claim('runtime-a', undefined, '/overflow')).toThrow('capacity reached') + + releases[0]?.() + releases[0]?.() + expect(() => admission.claim('runtime-a', undefined, '/recovered')).not.toThrow() + }) + + it('bounds multibyte roots before retaining a lease', () => { + const admission = new RuntimeFileWatcherAdmission() + const exact = 'é'.repeat(RUNTIME_FILE_WATCHER_MAX_PATH_BYTES / 2) + + expect(() => admission.claim('runtime-a', 'ssh-a', exact)).not.toThrow() + expect(() => admission.claim('runtime-a', 'ssh-a', `${exact}x`)).toThrow( + `exceeds ${RUNTIME_FILE_WATCHER_MAX_PATH_BYTES} UTF-8 bytes` + ) + expect(admission.evidence().leases).toBe(1) + }) +}) diff --git a/src/main/runtime/runtime-file-watcher-admission.ts b/src/main/runtime/runtime-file-watcher-admission.ts new file mode 100644 index 00000000000..94abfbe9dd8 --- /dev/null +++ b/src/main/runtime/runtime-file-watcher-admission.ts @@ -0,0 +1,53 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export const RUNTIME_FILE_WATCHER_MAX_LEASES = 256 +export const RUNTIME_FILE_WATCHER_MAX_RETAINED_IDENTITY_BYTES = 16 * 1024 * 1024 +export const RUNTIME_FILE_WATCHER_MAX_PATH_BYTES = 64 * 1024 +const RUNTIME_FILE_WATCHER_MAX_OWNER_ID_BYTES = 8 * 1024 + +export class RuntimeFileWatcherAdmission { + private leases = 0 + private retainedBytes = 0 + + claim(runtimeId: string, connectionId: string | undefined, rootPath: string): () => void { + const identityBytes = + boundedIdentityBytes(runtimeId, RUNTIME_FILE_WATCHER_MAX_OWNER_ID_BYTES, 'runtime id') + + boundedIdentityBytes(rootPath, RUNTIME_FILE_WATCHER_MAX_PATH_BYTES, 'root path') + + (connectionId + ? boundedIdentityBytes( + connectionId, + RUNTIME_FILE_WATCHER_MAX_OWNER_ID_BYTES, + 'connection id' + ) + : 0) + if ( + this.leases >= RUNTIME_FILE_WATCHER_MAX_LEASES || + this.retainedBytes + identityBytes > RUNTIME_FILE_WATCHER_MAX_RETAINED_IDENTITY_BYTES + ) { + throw new Error('Runtime file watcher capacity reached; close an existing watch and retry.') + } + this.leases += 1 + this.retainedBytes += identityBytes + let claimed = true + return () => { + if (!claimed) { + return + } + claimed = false + this.leases -= 1 + this.retainedBytes -= identityBytes + } + } + + evidence(): { leases: number; retainedBytes: number } { + return { leases: this.leases, retainedBytes: this.retainedBytes } + } +} + +function boundedIdentityBytes(value: string, limit: number, field: string): number { + const measured = measureUtf8ByteLength(value, { stopAfterBytes: limit }) + if (measured.exceededLimit) { + throw new Error(`Runtime file watcher ${field} exceeds ${limit} UTF-8 bytes.`) + } + return measured.byteLength +} diff --git a/src/main/runtime/runtime-mobile-file-path-search.test.ts b/src/main/runtime/runtime-mobile-file-path-search.test.ts index 495a7933842..177fd993e25 100644 --- a/src/main/runtime/runtime-mobile-file-path-search.test.ts +++ b/src/main/runtime/runtime-mobile-file-path-search.test.ts @@ -95,4 +95,21 @@ describe('RuntimeMobileFilePathSearchCache', () => { vi.useRealTimers() } }) + + it('rejects distinct scans beyond the in-flight cache bound', async () => { + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + const loads: ((value: RuntimeMobileFilePathInventory) => void)[] = [] + const load = () => + new Promise((resolve) => { + loads.push(resolve) + }) + const first = cache.get('a', load) + const second = cache.get('b', load) + + await expect(cache.get('c', load)).rejects.toThrow('search is busy') + + const inventory = { paths: [], totalCount: 0, truncated: false } + loads.forEach((resolve) => resolve(inventory)) + await expect(Promise.all([first, second])).resolves.toEqual([inventory, inventory]) + }) }) diff --git a/src/main/runtime/runtime-mobile-file-path-search.ts b/src/main/runtime/runtime-mobile-file-path-search.ts index 13213d6dfcf..61001b04bec 100644 --- a/src/main/runtime/runtime-mobile-file-path-search.ts +++ b/src/main/runtime/runtime-mobile-file-path-search.ts @@ -34,6 +34,9 @@ export class RuntimeMobileFilePathSearchCache { if (pending) { return pending } + if (this.inFlight.size >= this.maxEntries) { + throw new Error('Mobile file path search is busy; retry after current searches finish.') + } const next = load() .then((loaded) => { // Why: a slow SSH scan should receive a full TTL after it becomes usable, diff --git a/src/main/runtime/runtime-operation-generations.test.ts b/src/main/runtime/runtime-operation-generations.test.ts new file mode 100644 index 00000000000..310fd79add2 --- /dev/null +++ b/src/main/runtime/runtime-operation-generations.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_RUNTIME_OPERATION_GENERATIONS, + MAX_RUNTIME_OPERATION_GENERATION_KEY_BYTES, + MAX_RUNTIME_OPERATION_GENERATION_RETAINED_KEY_BYTES, + RuntimeOperationGenerations +} from './runtime-operation-generations' + +const BOUNDS = { + maxEntries: 2, + maxKeyBytes: 4, + maxRetainedKeyBytes: 6 +} + +describe('RuntimeOperationGenerations', () => { + it('keeps a stable generation until the operation advances', () => { + const generations = new RuntimeOperationGenerations(BOUNDS) + const initial = generations.current('a') + + expect(generations.current('a')).toBe(initial) + expect(generations.advance('a')).not.toBe(initial) + expect(generations.isCurrent('a', initial)).toBe(false) + }) + + it('evicts least-recently-used keys and fails closed for stale captures', () => { + const generations = new RuntimeOperationGenerations(BOUNDS) + const first = generations.current('a') + const evicted = generations.current('b') + generations.current('a') + const latest = generations.current('cc') + + expect(generations.isCurrent('a', first)).toBe(true) + expect(generations.isCurrent('b', evicted)).toBe(false) + expect(generations.isCurrent('cc', latest)).toBe(true) + expect(generations.evidence()).toEqual({ entries: 2, retainedKeyBytes: 3 }) + }) + + it('bounds aggregate retained key bytes', () => { + const generations = new RuntimeOperationGenerations({ + maxEntries: 3, + maxKeyBytes: 4, + maxRetainedKeyBytes: 3 + }) + const evicted = generations.current('aa') + generations.current('b') + generations.current('cc') + + expect(generations.isCurrent('aa', evicted)).toBe(false) + expect(generations.evidence()).toEqual({ entries: 2, retainedKeyBytes: 3 }) + }) + + it('does not retain oversized keys', () => { + const generations = new RuntimeOperationGenerations(BOUNDS) + const generation = generations.current('oversized') + + expect(generations.isCurrent('oversized', generation)).toBe(false) + expect(generations.evidence()).toEqual({ entries: 0, retainedKeyBytes: 0 }) + }) + + it('invalidates forgotten owners without retaining tombstones', () => { + const generations = new RuntimeOperationGenerations(BOUNDS) + const stale = generations.current('a') + generations.forget('a') + + expect(generations.isCurrent('a', stale)).toBe(false) + expect(generations.current('a')).not.toBe(stale) + expect(generations.evidence()).toEqual({ entries: 1, retainedKeyBytes: 1 }) + }) + + it('publishes explicit production bounds', () => { + expect(MAX_RUNTIME_OPERATION_GENERATIONS).toBe(8_192) + expect(MAX_RUNTIME_OPERATION_GENERATION_KEY_BYTES).toBe(64 * 1024) + expect(MAX_RUNTIME_OPERATION_GENERATION_RETAINED_KEY_BYTES).toBe(4 * 1024 * 1024) + }) +}) diff --git a/src/main/runtime/runtime-operation-generations.ts b/src/main/runtime/runtime-operation-generations.ts new file mode 100644 index 00000000000..8f566d277e7 --- /dev/null +++ b/src/main/runtime/runtime-operation-generations.ts @@ -0,0 +1,122 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export const MAX_RUNTIME_OPERATION_GENERATIONS = 8_192 +export const MAX_RUNTIME_OPERATION_GENERATION_KEY_BYTES = 64 * 1024 +export const MAX_RUNTIME_OPERATION_GENERATION_RETAINED_KEY_BYTES = 4 * 1024 * 1024 + +export type RuntimeOperationGenerationBounds = { + maxEntries: number + maxKeyBytes: number + maxRetainedKeyBytes: number +} + +const DEFAULT_BOUNDS: RuntimeOperationGenerationBounds = { + maxEntries: MAX_RUNTIME_OPERATION_GENERATIONS, + maxKeyBytes: MAX_RUNTIME_OPERATION_GENERATION_KEY_BYTES, + maxRetainedKeyBytes: MAX_RUNTIME_OPERATION_GENERATION_RETAINED_KEY_BYTES +} + +type RetainedGeneration = { + generation: number + keyBytes: number +} + +export class RuntimeOperationGenerations { + private readonly generations = new Map() + private nextGeneration = 1 + private retainedKeyBytes = 0 + + constructor(private readonly bounds: RuntimeOperationGenerationBounds = DEFAULT_BOUNDS) { + if ( + !Number.isSafeInteger(bounds.maxEntries) || + bounds.maxEntries < 1 || + !Number.isSafeInteger(bounds.maxKeyBytes) || + bounds.maxKeyBytes < 1 || + !Number.isSafeInteger(bounds.maxRetainedKeyBytes) || + bounds.maxRetainedKeyBytes < 1 + ) { + throw new RangeError('Runtime operation generation bounds must be positive integers') + } + } + + current(key: string): number { + const retained = this.generations.get(key) + if (!retained) { + // Why: a missing key may have been evicted, so it must never reuse a stale token. + return this.replace(key) + } + this.generations.delete(key) + this.generations.set(key, retained) + return retained.generation + } + + advance(key: string): number { + return this.replace(key) + } + + isCurrent(key: string, generation: number): boolean { + const retained = this.generations.get(key) + if (retained?.generation !== generation) { + return false + } + this.generations.delete(key) + this.generations.set(key, retained) + return true + } + + forget(key: string): void { + this.delete(key) + } + + evidence(): { entries: number; retainedKeyBytes: number } { + return { + entries: this.generations.size, + retainedKeyBytes: this.retainedKeyBytes + } + } + + private replace(key: string): number { + this.delete(key) + const generation = this.takeGeneration() + const measurement = measureUtf8ByteLength(key, { + stopAfterBytes: this.bounds.maxKeyBytes + }) + if (measurement.exceededLimit || measurement.byteLength > this.bounds.maxRetainedKeyBytes) { + return generation + } + while ( + this.generations.size >= this.bounds.maxEntries || + this.retainedKeyBytes + measurement.byteLength > this.bounds.maxRetainedKeyBytes + ) { + const oldest = this.generations.keys().next().value + if (oldest === undefined) { + return generation + } + this.delete(oldest) + } + this.generations.set(key, { + generation, + keyBytes: measurement.byteLength + }) + this.retainedKeyBytes += measurement.byteLength + return generation + } + + private takeGeneration(): number { + if (!Number.isSafeInteger(this.nextGeneration)) { + throw new Error('Runtime operation generation exhausted') + } + const generation = this.nextGeneration + this.nextGeneration += 1 + return generation + } + + private delete(key: string): void { + const retained = this.generations.get(key) + if (!retained) { + return + } + this.generations.delete(key) + this.retainedKeyBytes -= retained.keyBytes + } +} diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index e6c4363622a..18f86ce20c6 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -1333,6 +1333,92 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('applies the ask sub-cap on the WebSocket path and releases both counters on close', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + // Why: cap 4 → ask sub-cap 2, so the third ask must be shed while waits keep the other half. + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: false, + longPollCap: 4 + }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + // Why: 'runtime' scope, not 'mobile' — orchestration.ask is absent from the mobile allowlist. + const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') + const ws = new FakeWebSocket() + server['mobileSocketWiring'] = { + getConnectionId: () => 'conn-test' + } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> + const replies: Record[] = [] + const push = (response: string): void => { + replies.push(JSON.parse(response) as Record) + } + const dispatch = (id: string, method: string, params: unknown): Promise => + server['handleWebSocketMessage']( + JSON.stringify({ id, method, deviceToken: entry.token, params }), + push, + () => {}, + undefined, + ws as unknown as WebSocket + ) + + try { + const asks = [0, 1].map((i) => + dispatch(`req_ask_${i}`, 'orchestration.ask', { + from: `term_w${i}`, + to: 'term_coord', + question: 'proceed?', + timeoutMs: 10_000 + }) + ) + // Why: gate on the pre-existing total so a missing sub-cap fails on the shed below, not here. + await waitFor(() => server['activeLongPolls'] === 2) + + await dispatch('req_ask_overflow', 'orchestration.ask', { + from: 'term_w2', + to: 'term_coord', + question: 'proceed?', + timeoutMs: 10_000 + }) + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_ask_overflow', + ok: false, + error: expect.objectContaining({ + code: 'runtime_busy', + message: 'orchestration.ask capacity reached; retry with backoff' + }) + }) + ) + // Shedding the ask must not burn a slot from the reserved half. + expect(server['activeLongPolls']).toBe(2) + expect(server['activeAskLongPolls']).toBe(2) + + const wait = dispatch('req_check_wait', 'orchestration.check', { + terminal: 'term_other', + wait: true, + timeoutMs: 10_000 + }) + await waitFor(() => server['activeLongPolls'] === 3) + expect(server['activeAskLongPolls']).toBe(2) + + ws.readyState = 3 + ws.emit('close') + await Promise.all([...asks, wait]) + + expect(server['activeLongPolls']).toBe(0) + expect(server['activeAskLongPolls']).toBe(0) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_ask_0', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_check_wait', ok: true })) + } finally { + db.close() + await server.stop() + } + }) + it('shares one socket close listener across concurrent WebSocket dispatches', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService @@ -1408,17 +1494,6 @@ describe('OrcaRuntimeRpcServer', () => { const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true }) const selectClaudeAccount = vi.fn().mockResolvedValue({ ok: true }) const selectCodexAccount = vi.fn().mockResolvedValue({ ok: true }) - const expectedCodexResetScope = { - target: { runtime: 'host' as const, wslDistro: null }, - accountId: 'codex-account', - accountRevision: 42, - offerRevision: 'v1:offer' - } - const consumeCodexRateLimitResetCredit = vi.fn().mockResolvedValue({ - outcome: 'reset', - scope: expectedCodexResetScope, - snapshot: { claude: null, codex: null } - }) const removeClaudeAccount = vi.fn().mockResolvedValue({ ok: true }) const readTerminal = vi.fn().mockResolvedValue({ tail: ['ok'] }) const getRuntimeGitStatus = vi @@ -1507,7 +1582,6 @@ describe('OrcaRuntimeRpcServer', () => { pushRuntimeGit, selectClaudeAccount, selectCodexAccount, - consumeCodexRateLimitResetCredit, removeClaudeAccount, readTerminal, getRuntimeGitStatus, @@ -2141,19 +2215,6 @@ describe('OrcaRuntimeRpcServer', () => { (response) => replies.push(JSON.parse(response) as Record), () => {} ) - await server['handleWebSocketMessage']( - JSON.stringify({ - id: 'req_consume_codex_reset', - method: 'accounts.consumeCodexResetCredit', - deviceToken: mobile.token, - params: { - idempotencyKey: '11111111-1111-4111-8111-111111111111', - expectedScope: expectedCodexResetScope - } - }), - (response) => replies.push(JSON.parse(response) as Record), - () => {} - ) await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_remove_claude', @@ -2359,9 +2420,6 @@ describe('OrcaRuntimeRpcServer', () => { ) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_claude', ok: true })) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_codex', ok: true })) - expect(replies).toContainEqual( - expect.objectContaining({ id: 'req_consume_codex_reset', ok: true }) - ) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_terminal_read', ok: true })) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_files_open_diff', ok: true })) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_diff', ok: true })) @@ -2393,10 +2451,6 @@ describe('OrcaRuntimeRpcServer', () => { ) expect(selectClaudeAccount).toHaveBeenCalledWith('claude-account') expect(selectCodexAccount).toHaveBeenCalledWith(null) - expect(consumeCodexRateLimitResetCredit).toHaveBeenCalledWith( - '11111111-1111-4111-8111-111111111111', - expectedCodexResetScope - ) expect(readTerminal).toHaveBeenCalledWith('term-1', { cursor: undefined }) expect(getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1') expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined, undefined) @@ -3941,51 +3995,6 @@ describe('OrcaRuntimeRpcServer', () => { } }) - it('emits keepalive frames while orchestration.ask blocks for a reply', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) - const runtime = new OrcaRuntimeService() - const db = new OrchestrationDb(':memory:') - runtime.setOrchestrationDb(db) - const server = new OrcaRuntimeRpcServer({ - runtime, - userDataPath, - keepaliveIntervalMs: 50 - }) - await server.start() - - try { - const metadata = readRuntimeMetadata(userDataPath) - // Why: no reply is ever sent, so ask blocks the full window on the same - // hold-the-socket path check --wait uses. Without ask in the long-poll - // set the 30s idle timer would tear this down before it keepalives. - const session = openFramedSession(metadata!.transports[0]!.endpoint, { - id: 'req_ask', - authToken: metadata!.authToken, - method: 'orchestration.ask', - params: { - to: 'term_nobody', - from: 'term_asker', - question: 'ping?', - timeoutMs: 300 - } - }) - await session.done - - const keepalives = session.frames.filter((f) => f._keepalive === true) - const terminals = session.frames.filter((f) => f.ok !== undefined) - expect(terminals).toHaveLength(1) - expect(terminals[0]).toMatchObject({ - id: 'req_ask', - ok: true, - result: { timedOut: true } - }) - expect(keepalives.length).toBeGreaterThanOrEqual(3) - } finally { - db.close() - await server.stop() - } - }) - it('emits keepalive frames while terminal.wait blocks and returns its structured timeout', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() @@ -4289,6 +4298,166 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('reserves long-poll headroom for terminal.wait when orchestration.ask floods', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + // Why: cap 4 → ask sub-cap 2, so 4 concurrent asks can only take half the budget. + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 1000, + longPollCap: 4 + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Terminal 1', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + await server.start() + + const asks: ReturnType[] = [] + try { + const metadata = readRuntimeMetadata(userDataPath) + const endpoint = metadata!.transports[0]!.endpoint + const listResponse = await sendRequest(endpoint, { + id: 'req_list', + authToken: metadata!.authToken, + method: 'terminal.list' + }) + const handle = (listResponse.result as { terminals: { handle: string }[] }).terminals[0]! + .handle + + // Four workers block in ask; distinct `from` handles so no reply wakes another. + for (let i = 0; i < 4; i++) { + asks.push( + openFramedSession(endpoint, { + id: `req_ask_${i}`, + authToken: metadata!.authToken, + method: 'orchestration.ask', + params: { + from: `term_w${i}`, + to: 'term_coord', + question: 'proceed?', + timeoutMs: 10_000 + } + }) + ) + } + // Let every ask reach the admission fence before probing the reserved half. + await waitFor(() => server['activeLongPolls'] >= 2) + await sleep(100) + + // The reserved half still admits a terminal.wait from any other client. + const admitted = openFramedSession(endpoint, { + id: 'req_terminal_wait', + authToken: metadata!.authToken, + method: 'terminal.wait', + params: { terminal: handle, for: 'tui-idle', timeoutMs: 50 } + }) + await admitted.done + expect(admitted.frames.find((f) => f.ok !== undefined)).toMatchObject({ + id: 'req_terminal_wait', + ok: false, + error: { code: 'timeout' } + }) + + // …and a check --wait too, which shares the same reserved class. + const check = openFramedSession(endpoint, { + id: 'req_check_wait', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_other', wait: true, timeoutMs: 100 } + }) + await check.done + expect(check.frames.find((f) => f.ok !== undefined)).toMatchObject({ + id: 'req_check_wait', + ok: true + }) + + // Overflow asks are shed, not queued: the sub-cap holds at half the budget. + expect(server['activeAskLongPolls']).toBe(2) + const shed = asks + .map((a) => a.frames.find((f) => f.ok !== undefined)) + .filter((f) => f !== undefined) + expect(shed).toHaveLength(2) + expect(shed[0]).toMatchObject({ ok: false, error: { code: 'runtime_busy' } }) + } finally { + for (const ask of asks) { + ask.socket.destroy() + } + await Promise.all(asks.map((ask) => ask.done)) + db.close() + await server.stop() + } + }) + + it('keeps the full cap available to terminal.wait and check --wait', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 1000, + longPollCap: 4 + }) + await server.start() + + const waits: ReturnType[] = [] + try { + const metadata = readRuntimeMetadata(userDataPath) + const endpoint = metadata!.transports[0]!.endpoint + + // The ask sub-cap must not narrow the budget for the reserved class. + for (let i = 0; i < 4; i++) { + waits.push( + openFramedSession(endpoint, { + id: `req_wait_${i}`, + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: `term_${i}`, wait: true, timeoutMs: 10_000 } + }) + ) + } + await waitFor(() => server['activeLongPolls'] === 4) + expect(server['activeAskLongPolls']).toBe(0) + + const overflow = await sendRequest(endpoint, { + id: 'req_overflow', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_overflow', wait: true, timeoutMs: 5_000 } + }) + expect(overflow).toMatchObject({ ok: false, error: { code: 'runtime_busy' } }) + } finally { + for (const wait of waits) { + wait.socket.destroy() + } + await Promise.all(waits.map((wait) => wait.done)) + db.close() + await server.stop() + } + }) + it('does not emit keepalive frames for short RPCs', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 0d399aa72e8..7d07a986047 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -41,6 +41,7 @@ import { decodeTerminalStreamFrame, type TerminalStreamFrame } from '../../shared/terminal-stream-protocol' +import { parseRemoteRuntimeJsonText } from '../../shared/remote-runtime-request-frames' const DEFAULT_WS_PORT = 6768 @@ -54,7 +55,7 @@ type OrcaRuntimeRpcServerOptions = { // Why: true when the caller pinned a port (`orca serve --port`) so bind order prefers it over a stale STA-1511 fallback (#8535). preferPinnedWsPort?: boolean webClientRoot?: string - // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). + // Why: test-only overrides for the admission constants below; production uses the defaults. keepaliveIntervalMs?: number longPollCap?: number } @@ -115,6 +116,12 @@ const KEEPALIVE_INTERVAL_MS = 10_000 // Why: cap long-polls at half the 32-slot connection budget so they can't starve short RPCs; overflow → runtime_busy. See §7 risk #2. const LONG_POLL_CAP = 16 +// Why: orchestration.ask blocks on a human/agent reply for minutes, an order of +// magnitude longer than terminal.wait or check --wait, so a fleet of asking +// workers would otherwise hold every slot and starve the mobile/web/CLI/relay +// clients sharing this runtime. Reserve half the budget for the other classes. +const ASK_LONG_POLL_SHARE = 0.5 + function createWebClientUrl(endpoint: string, pairingUrl: string): string { const url = new URL(endpoint) url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:' @@ -394,10 +401,13 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'worktree.sleep' ]) +// Why: 'ask' is metered separately from 'wait' — same keepalive/abort wiring, its own sub-cap. +type LongPollClass = 'ask' | 'wait' + // Why: single classifier for long-poll requests (handlers that block on an external event), shared by counter/abort/keepalive. See §3.1. -function isLongPollRequest(request: RpcRequest): boolean { +function longPollClassOf(request: RpcRequest): LongPollClass | null { if (request.method === 'terminal.wait') { - return true + return 'wait' } // Why: orchestration.ask blocks unconditionally (default 600 s) holding the // RPC open until a reply lands or the deadline passes, so it needs the same @@ -405,13 +415,13 @@ function isLongPollRequest(request: RpcRequest): boolean { // also relies on the abort signal (only wired for long-polls) to release the // waiter when the asking client disconnects. if (request.method === 'orchestration.ask') { - return true + return 'ask' } if (request.method === 'orchestration.check') { const params = request.params as { wait?: unknown } | undefined - return params?.wait === true + return params?.wait === true ? 'wait' : null } - return false + return null } // Why: status.get has no per-connection context in the dispatcher, so stamp the scope here at the transport boundary. @@ -441,6 +451,7 @@ export class OrcaRuntimeRpcServer { private readonly authToken = randomBytes(24).toString('hex') private readonly keepaliveIntervalMs: number private readonly longPollCap: number + private readonly askLongPollCap: number private readonly relayRevokeOutbox: RelayRevokeOutbox private deviceRegistry: DeviceRegistry | null = null private e2eeKeypair: E2EEKeypair | null = null @@ -462,6 +473,8 @@ export class OrcaRuntimeRpcServer { >() // Why: separate from server.maxConnections — count only long-running dispatches, not short RPCs. See §3.1 + §7 risk #2. private activeLongPolls = 0 + // Why: subset of activeLongPolls held by orchestration.ask, fenced by askLongPollCap. + private activeAskLongPolls = 0 constructor({ runtime, @@ -486,6 +499,8 @@ export class OrcaRuntimeRpcServer { this.webClientRoot = webClientRoot this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap + // Why: derived, not configurable — the reservation must hold for whatever cap a caller picks. + this.askLongPollCap = Math.max(1, Math.floor(longPollCap * ASK_LONG_POLL_SHARE)) this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath) } @@ -1020,16 +1035,12 @@ export class OrcaRuntimeRpcServer { const request = parsed.request // Why: long-poll admission fence; short RPCs bypass the counter. See §7 risk #2. - const longPoll = isLongPollRequest(request) - if (longPoll && this.activeLongPolls >= this.longPollCap) { - return this.buildError( - request.id, - 'runtime_busy', - 'long-poll capacity reached; retry with backoff' - ) + const longPoll = longPollClassOf(request) + const rejection = this.admitLongPoll(longPoll) + if (rejection) { + return this.buildError(request.id, 'runtime_busy', rejection) } if (longPoll) { - this.activeLongPolls += 1 // Why: arm keepalive only for long-polls; short RPCs never create the setInterval. See §3.1. context?.startKeepalive() } @@ -1039,16 +1050,44 @@ export class OrcaRuntimeRpcServer { signal: longPoll ? context?.signal : undefined }) } finally { - if (longPoll) { - this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) - } + this.releaseLongPoll(longPoll) + } + } + + // Why: one fence for both transports — the total cap protects short RPCs, the ask + // sub-cap protects terminal.wait / check --wait from slow reply-blocked asks. + // Returns the rejection message, or null once the slot is reserved. + private admitLongPoll(longPoll: LongPollClass | null): string | null { + if (!longPoll) { + return null + } + if (this.activeLongPolls >= this.longPollCap) { + return 'long-poll capacity reached; retry with backoff' + } + if (longPoll === 'ask' && this.activeAskLongPolls >= this.askLongPollCap) { + return 'orchestration.ask capacity reached; retry with backoff' + } + this.activeLongPolls += 1 + if (longPoll === 'ask') { + this.activeAskLongPolls += 1 + } + return null + } + + private releaseLongPoll(longPoll: LongPollClass | null): void { + if (!longPoll) { + return + } + this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) + if (longPoll === 'ask') { + this.activeAskLongPolls = Math.max(0, this.activeAskLongPolls - 1) } } private parseAndAuth(rawMessage: string): { request: RpcRequest } | { error: RpcResponse } { let request: RpcRequest try { - request = JSON.parse(rawMessage) as RpcRequest + request = parseRemoteRuntimeJsonText(rawMessage) as RpcRequest } catch { return { error: this.buildError('unknown', 'bad_request', 'Invalid JSON request') } } @@ -1081,7 +1120,7 @@ export class OrcaRuntimeRpcServer { ): Promise { let request: RpcRequest try { - request = JSON.parse(rawMessage) as RpcRequest + request = parseRemoteRuntimeJsonText(rawMessage) as RpcRequest } catch { reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Invalid JSON request'))) return @@ -1133,24 +1172,14 @@ export class OrcaRuntimeRpcServer { wsTransport.setClientId(ws, token) } - const longPoll = isLongPollRequest(request) - if (longPoll && this.activeLongPolls >= this.longPollCap) { - reply( - JSON.stringify( - this.buildError( - request.id, - 'runtime_busy', - 'long-poll capacity reached; retry with backoff' - ) - ) - ) + const longPoll = longPollClassOf(request) + const rejection = this.admitLongPoll(longPoll) + if (rejection) { + reply(JSON.stringify(this.buildError(request.id, 'runtime_busy', rejection))) return } const abortRegistration = ws ? this.registerWebSocketDispatchAbort(ws) : null - if (longPoll) { - this.activeLongPolls += 1 - } // Why: older pairings may lack scope metadata, so stamp the authenticated scope onto status.get. const replyForRequest = @@ -1198,9 +1227,7 @@ export class OrcaRuntimeRpcServer { }) } finally { abortRegistration?.dispose() - if (longPoll) { - this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) - } + this.releaseLongPoll(longPoll) } } diff --git a/src/main/runtime/runtime-ssh-relay-recovery-generations.test.ts b/src/main/runtime/runtime-ssh-relay-recovery-generations.test.ts new file mode 100644 index 00000000000..bca69548e8e --- /dev/null +++ b/src/main/runtime/runtime-ssh-relay-recovery-generations.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_RUNTIME_SSH_RELAY_RECOVERIES, + MAX_RUNTIME_SSH_RELAY_RECOVERY_RETAINED_TARGET_ID_BYTES, + MAX_RUNTIME_SSH_RELAY_RECOVERY_TARGET_ID_BYTES, + RuntimeSshRelayRecoveryGenerations +} from './runtime-ssh-relay-recovery-generations' + +describe('RuntimeSshRelayRecoveryGenerations', () => { + it('releases completed recoveries under sequential unique-target churn', () => { + const generations = new RuntimeSshRelayRecoveryGenerations() + + for (let index = 0; index < 10_000; index += 1) { + const targetId = `ssh-${index}` + const lease = generations.begin(targetId) + expect(lease).not.toBeNull() + lease?.release() + } + + expect(generations.evidence()).toEqual({ recoveries: 0, retainedTargetIdBytes: 0 }) + }) + + it('keeps a replacement current when an older recovery finishes late', () => { + const generations = new RuntimeSshRelayRecoveryGenerations() + const stale = generations.begin('ssh-1')! + const replacement = generations.begin('ssh-1')! + + stale.release() + + expect(stale.isCurrent()).toBe(false) + expect(replacement.isCurrent()).toBe(true) + expect(generations.evidence()).toEqual({ + recoveries: 1, + retainedTargetIdBytes: 5 + }) + }) + + it('caps same-target hung replacements by outstanding lease count', () => { + const generations = new RuntimeSshRelayRecoveryGenerations() + const leases = Array.from({ length: 10_000 }, () => generations.begin('ssh-1')).filter( + (lease) => lease !== null + ) + + expect(leases).toHaveLength(MAX_RUNTIME_SSH_RELAY_RECOVERIES) + expect(leases.slice(0, -1).every((lease) => !lease.isCurrent())).toBe(true) + expect(leases.at(-1)?.isCurrent()).toBe(true) + expect(generations.evidence()).toEqual({ + recoveries: MAX_RUNTIME_SSH_RELAY_RECOVERIES, + retainedTargetIdBytes: MAX_RUNTIME_SSH_RELAY_RECOVERIES * 5 + }) + + for (const lease of leases) { + lease.release() + } + expect(generations.evidence()).toEqual({ recoveries: 0, retainedTargetIdBytes: 0 }) + }) + + it('keeps invalidated attempts admitted until their work releases', () => { + const generations = new RuntimeSshRelayRecoveryGenerations({ + maxRecoveries: 1, + maxTargetIdBytes: 8, + maxRetainedTargetIdBytes: 8 + }) + const stale = generations.begin('ssh-1')! + + generations.invalidate('ssh-1') + + expect(stale.isCurrent()).toBe(false) + expect(generations.begin('ssh-2')).toBeNull() + stale.release() + expect(generations.begin('ssh-2')?.isCurrent()).toBe(true) + }) + + it('rejects unique hung recoveries at the concurrent-entry cap and admits after release', () => { + const generations = new RuntimeSshRelayRecoveryGenerations() + const leases = Array.from({ length: MAX_RUNTIME_SSH_RELAY_RECOVERIES }, (_, index) => + generations.begin(`ssh-${index}`) + ) + + expect(leases.every(Boolean)).toBe(true) + expect(generations.begin('ssh-overflow')).toBeNull() + leases[0]?.release() + + const replacement = generations.begin('ssh-overflow') + expect(replacement?.isCurrent()).toBe(true) + for (const lease of leases) { + lease?.release() + } + replacement?.release() + expect(generations.evidence()).toEqual({ recoveries: 0, retainedTargetIdBytes: 0 }) + }) + + it('bounds individual and aggregate retained target-id bytes', () => { + const generations = new RuntimeSshRelayRecoveryGenerations({ + maxRecoveries: 3, + maxTargetIdBytes: 8, + maxRetainedTargetIdBytes: 10 + }) + const first = generations.begin('123456') + const second = generations.begin('abcd') + + expect(first?.isCurrent()).toBe(true) + expect(second?.isCurrent()).toBe(true) + expect(generations.begin('x')).toBeNull() + expect(generations.begin('🌊🌊🌊')).toBeNull() + expect(generations.evidence()).toEqual({ recoveries: 2, retainedTargetIdBytes: 10 }) + }) + + it('publishes explicit production bounds', () => { + expect(MAX_RUNTIME_SSH_RELAY_RECOVERIES).toBe(256) + expect(MAX_RUNTIME_SSH_RELAY_RECOVERY_TARGET_ID_BYTES).toBe(64 * 1024) + expect(MAX_RUNTIME_SSH_RELAY_RECOVERY_RETAINED_TARGET_ID_BYTES).toBe(4 * 1024 * 1024) + }) +}) diff --git a/src/main/runtime/runtime-ssh-relay-recovery-generations.ts b/src/main/runtime/runtime-ssh-relay-recovery-generations.ts new file mode 100644 index 00000000000..8f6aecb06a3 --- /dev/null +++ b/src/main/runtime/runtime-ssh-relay-recovery-generations.ts @@ -0,0 +1,108 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export const MAX_RUNTIME_SSH_RELAY_RECOVERIES = 256 +export const MAX_RUNTIME_SSH_RELAY_RECOVERY_TARGET_ID_BYTES = 64 * 1024 +export const MAX_RUNTIME_SSH_RELAY_RECOVERY_RETAINED_TARGET_ID_BYTES = 4 * 1024 * 1024 + +export type RuntimeSshRelayRecoveryGenerationBounds = { + maxRecoveries: number + maxTargetIdBytes: number + maxRetainedTargetIdBytes: number +} + +export type RuntimeSshRelayRecoveryGenerationLease = { + isCurrent: () => boolean + release: () => void +} + +const DEFAULT_BOUNDS: RuntimeSshRelayRecoveryGenerationBounds = { + maxRecoveries: MAX_RUNTIME_SSH_RELAY_RECOVERIES, + maxTargetIdBytes: MAX_RUNTIME_SSH_RELAY_RECOVERY_TARGET_ID_BYTES, + maxRetainedTargetIdBytes: MAX_RUNTIME_SSH_RELAY_RECOVERY_RETAINED_TARGET_ID_BYTES +} + +type RetainedRecovery = { + generation: number + targetIdBytes: number +} + +export class RuntimeSshRelayRecoveryGenerations { + private readonly generationByTargetId = new Map() + private activeRecoveries = 0 + private retainedTargetIdBytes = 0 + private nextGeneration = 1 + + constructor(private readonly bounds: RuntimeSshRelayRecoveryGenerationBounds = DEFAULT_BOUNDS) { + if ( + !Number.isSafeInteger(bounds.maxRecoveries) || + bounds.maxRecoveries < 1 || + !Number.isSafeInteger(bounds.maxTargetIdBytes) || + bounds.maxTargetIdBytes < 1 || + !Number.isSafeInteger(bounds.maxRetainedTargetIdBytes) || + bounds.maxRetainedTargetIdBytes < 1 + ) { + throw new RangeError('SSH relay recovery generation bounds must be positive integers') + } + } + + begin(targetId: string): RuntimeSshRelayRecoveryGenerationLease | null { + const targetIdMeasurement = measureUtf8ByteLength(targetId, { + stopAfterBytes: this.bounds.maxTargetIdBytes + }) + if (targetIdMeasurement.exceededLimit) { + return null + } + if ( + this.activeRecoveries >= this.bounds.maxRecoveries || + this.retainedTargetIdBytes + targetIdMeasurement.byteLength > + this.bounds.maxRetainedTargetIdBytes + ) { + return null + } + if (!Number.isSafeInteger(this.nextGeneration)) { + throw new Error('SSH relay recovery generation exhausted') + } + const generation = this.nextGeneration + this.nextGeneration += 1 + const retained = { + generation, + targetIdBytes: targetIdMeasurement.byteLength + } + this.generationByTargetId.set(targetId, retained) + this.activeRecoveries += 1 + this.retainedTargetIdBytes += retained.targetIdBytes + let active = true + return { + isCurrent: () => + active && + this.generationByTargetId.get(targetId) === retained && + retained.generation === generation, + release: () => { + if (!active) { + return + } + active = false + this.releaseLease(targetId, retained) + } + } + } + + invalidate(targetId: string): void { + this.generationByTargetId.delete(targetId) + } + + evidence(): { recoveries: number; retainedTargetIdBytes: number } { + return { + recoveries: this.activeRecoveries, + retainedTargetIdBytes: this.retainedTargetIdBytes + } + } + + private releaseLease(targetId: string, retained: RetainedRecovery): void { + this.activeRecoveries -= 1 + this.retainedTargetIdBytes -= retained.targetIdBytes + if (this.generationByTargetId.get(targetId) === retained) { + this.generationByTargetId.delete(targetId) + } + } +} diff --git a/src/main/runtime/runtime-terminal-file-grant-retention.test.ts b/src/main/runtime/runtime-terminal-file-grant-retention.test.ts new file mode 100644 index 00000000000..7634f0e1bec --- /dev/null +++ b/src/main/runtime/runtime-terminal-file-grant-retention.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' +import { retainRuntimeTerminalFileGrant } from './runtime-terminal-file-grant-retention' + +describe('retainRuntimeTerminalFileGrant', () => { + it('preserves grants below the cap and evicts the oldest at admission', () => { + const grants = new Map() + const release = vi.fn((id: string) => grants.delete(id)) + + retainRuntimeTerminalFileGrant(grants, { id: 'a' }, release, 2) + retainRuntimeTerminalFileGrant(grants, { id: 'b' }, release, 2) + expect([...grants.keys()]).toEqual(['a', 'b']) + expect(release).not.toHaveBeenCalled() + + retainRuntimeTerminalFileGrant(grants, { id: 'c' }, release, 2) + expect([...grants.keys()]).toEqual(['b', 'c']) + expect(release).toHaveBeenCalledWith('a', { id: 'a' }) + }) +}) diff --git a/src/main/runtime/runtime-terminal-file-grant-retention.ts b/src/main/runtime/runtime-terminal-file-grant-retention.ts new file mode 100644 index 00000000000..24ef7efea76 --- /dev/null +++ b/src/main/runtime/runtime-terminal-file-grant-retention.ts @@ -0,0 +1,22 @@ +export const RUNTIME_TERMINAL_FILE_GRANT_MAX_ENTRIES = 1024 + +export function retainRuntimeTerminalFileGrant( + grants: Map, + grant: T, + release: (id: string, retained: T) => void, + maxEntries = RUNTIME_TERMINAL_FILE_GRANT_MAX_ENTRIES +): void { + while (grants.size >= Math.max(1, maxEntries)) { + const oldestId = grants.keys().next().value as string | undefined + if (oldestId === undefined) { + break + } + const oldest = grants.get(oldestId) + if (oldest) { + release(oldestId, oldest) + } else { + grants.delete(oldestId) + } + } + grants.set(grant.id, grant) +} diff --git a/src/main/runtime/runtime-text-search-admission.test.ts b/src/main/runtime/runtime-text-search-admission.test.ts new file mode 100644 index 00000000000..e245160aba6 --- /dev/null +++ b/src/main/runtime/runtime-text-search-admission.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { assertRuntimeTextSearchAdmission } from './runtime-text-search-admission' + +describe('assertRuntimeTextSearchAdmission', () => { + it('allows ordinary and replacement searches but rejects excess distinct roots', () => { + const active = new Map([ + ['repo-a', {}], + ['repo-b', {}] + ]) + + expect(() => assertRuntimeTextSearchAdmission(active, 'repo-a', 2)).not.toThrow() + expect(() => assertRuntimeTextSearchAdmission(active, 'repo-c', 2)).toThrow('search is busy') + }) +}) diff --git a/src/main/runtime/runtime-text-search-admission.ts b/src/main/runtime/runtime-text-search-admission.ts new file mode 100644 index 00000000000..78517515a32 --- /dev/null +++ b/src/main/runtime/runtime-text-search-admission.ts @@ -0,0 +1,11 @@ +export const RUNTIME_TEXT_SEARCH_MAX_ACTIVE = 16 + +export function assertRuntimeTextSearchAdmission( + activeSearchKeys: ReadonlyMap, + requestedKey: string, + maxActive = RUNTIME_TEXT_SEARCH_MAX_ACTIVE +): void { + if (!activeSearchKeys.has(requestedKey) && activeSearchKeys.size >= maxActive) { + throw new Error('Runtime text search is busy; retry after current searches finish.') + } +} diff --git a/src/main/serve-update-handoff.test.ts b/src/main/serve-update-handoff.test.ts index d649243a8f5..140ea03da33 100644 --- a/src/main/serve-update-handoff.test.ts +++ b/src/main/serve-update-handoff.test.ts @@ -1,10 +1,12 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' import { EventEmitter } from 'node:events' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SERVE_UPDATE_HANDOFF_PATH_ENV, + MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES, + MAX_SERVE_UPDATE_HANDOFF_JSON_STRUCTURAL_TOKENS, getServeUpdateHandoffPath, parseServeUpdateHandoffState } from '../shared/serve-update-handoff' @@ -81,6 +83,38 @@ describe('serve update handoff', () => { expect(requestServeUpdateHandoff('1.0.61')).toBe(false) }) + it('ignores oversized sparse handoff state', async () => { + const handoffPath = getServeUpdateHandoffPath(root) + writeFileSync(handoffPath, '{"schemaVersion":1}') + truncateSync(handoffPath, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES + 1) + const { getServeUpdateHandoffFailure } = await import('./serve-update-handoff') + + expect(getServeUpdateHandoffFailure()).toBeNull() + }) + + it('rejects structurally amplified handoff state before parsing', async () => { + const handoffPath = getServeUpdateHandoffPath(root) + writeFileSync( + handoffPath, + `{"padding":[${'0,'.repeat(MAX_SERVE_UPDATE_HANDOFF_JSON_STRUCTURAL_TOKENS)}0]}` + ) + const parseSpy = vi.spyOn(JSON, 'parse') + const { getServeUpdateHandoffFailure } = await import('./serve-update-handoff') + + expect(getServeUpdateHandoffFailure()).toBeNull() + expect(parseSpy).not.toHaveBeenCalled() + }) + + it('preserves prior handoff state when the replacement exceeds its read ceiling', async () => { + const { requestServeUpdateHandoff } = await import('./serve-update-handoff') + expect(requestServeUpdateHandoff('1.0.61')).toBe(true) + const handoffPath = getServeUpdateHandoffPath(root) + const before = readFileSync(handoffPath, 'utf8') + + expect(requestServeUpdateHandoff('x'.repeat(MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES))).toBe(false) + expect(readFileSync(handoffPath, 'utf8')).toBe(before) + }) + it.runIf(process.platform === 'darwin')( 'quits a supervised serve child when its CLI parent is lost', async () => { diff --git a/src/main/serve-update-handoff.ts b/src/main/serve-update-handoff.ts index ab831a90d49..038ff5d22ce 100644 --- a/src/main/serve-update-handoff.ts +++ b/src/main/serve-update-handoff.ts @@ -1,10 +1,13 @@ -import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { app } from 'electron' +import { readNodeFileSyncWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import { stringifyJsonWithinByteLimit } from '../shared/memory-safety/node-bounded-json-stringify' import { + MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES, SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath, - parseServeUpdateHandoffState, + parseServeUpdateHandoffJson, type ServeSupervisorMessage, type ServeUpdateHandoffState } from '../shared/serve-update-handoff' @@ -43,7 +46,11 @@ export function failServeUpdateHandoff(reason: string): void { return } try { - const state = parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8'))) + const state = parseServeUpdateHandoffJson( + readNodeFileSyncWithinLimit(handoffPath, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES).buffer.toString( + 'utf8' + ) + ) if (state?.phase !== 'install-requested' || state.servingPid !== process.pid) { return } @@ -90,7 +97,11 @@ export function getServeUpdateHandoffFailure(): string | null { return null } try { - const state = parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8'))) + const state = parseServeUpdateHandoffJson( + readNodeFileSyncWithinLimit(handoffPath, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES).buffer.toString( + 'utf8' + ) + ) if (state?.phase !== 'failed') { return null } @@ -107,11 +118,17 @@ export function getServeUpdateHandoffFailure(): string | null { function writeHandoffState(path: string, state: ServeUpdateHandoffState): boolean { const temporaryPath = `${path}.${process.pid}.tmp` try { + const { serialized } = stringifyJsonWithinByteLimit(state, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES) mkdirSync(dirname(path), { recursive: true }) - writeFileSync(temporaryPath, JSON.stringify(state), { mode: 0o600 }) + writeFileSync(temporaryPath, serialized, { mode: 0o600 }) renameSync(temporaryPath, path) return true } catch { + try { + unlinkSync(temporaryPath) + } catch { + // The temporary handoff is best-effort state and may not have been created. + } return false } } diff --git a/src/main/setup-script-import-file.test.ts b/src/main/setup-script-import-file.test.ts new file mode 100644 index 00000000000..635c896212a --- /dev/null +++ b/src/main/setup-script-import-file.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { NodeFileReadTooLargeError } from '../shared/memory-safety/node-bounded-file-reader' +import { + isSetupScriptImportTextWithinLimit, + readSetupScriptImportFile, + SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES +} from './setup-script-import-file' + +describe('setup script import file bounds', () => { + const roots: string[] = [] + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + + async function makeFile(contents: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-setup-script-import-')) + roots.push(root) + const filePath = join(root, 'config.json') + await writeFile(filePath, contents) + return filePath + } + + it('preserves ordinary configuration text', async () => { + const filePath = await makeFile('{"setup":"pnpm install"}') + + await expect(readSetupScriptImportFile(filePath)).resolves.toBe('{"setup":"pnpm install"}') + }) + + it('rejects an oversized sparse local configuration', async () => { + const filePath = await makeFile('x') + await truncate(filePath, SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES + 1) + + await expect(readSetupScriptImportFile(filePath)).rejects.toBeInstanceOf( + NodeFileReadTooLargeError + ) + }) + + it('measures remote configuration strings by UTF-8 bytes', () => { + expect(isSetupScriptImportTextWithinLimit('a'.repeat(SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES))).toBe( + true + ) + expect( + isSetupScriptImportTextWithinLimit('a'.repeat(SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES + 1)) + ).toBe(false) + expect( + isSetupScriptImportTextWithinLimit('é'.repeat(SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES / 2)) + ).toBe(true) + expect( + isSetupScriptImportTextWithinLimit('é'.repeat(SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES / 2 + 1)) + ).toBe(false) + }) +}) diff --git a/src/main/setup-script-import-file.ts b/src/main/setup-script-import-file.ts new file mode 100644 index 00000000000..3b5d0755d2a --- /dev/null +++ b/src/main/setup-script-import-file.ts @@ -0,0 +1,18 @@ +import { readNodeFileWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import { + isSetupScriptImportTextWithinLimit, + SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, + SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS +} from '../shared/setup-script-import-limits' + +export { + isSetupScriptImportTextWithinLimit, + SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, + SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS +} + +export async function readSetupScriptImportFile(filePath: string): Promise { + return ( + await readNodeFileWithinLimit(filePath, SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES) + ).buffer.toString('utf-8') +} diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.test.ts b/src/main/skills/claude-plugin-skill-sources-wsl.test.ts index 4d4f1add82c..216d8385699 100644 --- a/src/main/skills/claude-plugin-skill-sources-wsl.test.ts +++ b/src/main/skills/claude-plugin-skill-sources-wsl.test.ts @@ -35,4 +35,8 @@ describe('WSL Claude plugin metadata', () => { 'invalid response' ) }) + + it('parses delimiter-heavy output without materializing a field array', () => { + expect(parseWslClaudePluginMetadataOutput('\0'.repeat(1_000_000), 2)).toEqual([null, null]) + }) }) diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.ts b/src/main/skills/claude-plugin-skill-sources-wsl.ts index d3adddfde55..bf22d9aba0f 100644 --- a/src/main/skills/claude-plugin-skill-sources-wsl.ts +++ b/src/main/skills/claude-plugin-skill-sources-wsl.ts @@ -7,6 +7,10 @@ import { type ClaudePluginMetadata } from './claude-plugin-skill-sources' import type { SkillScanRoot } from './skill-discovery-sources' +import { + readWslSkillProtocolField, + type WslSkillProtocolFieldCursor +} from './wsl-skill-protocol-fields' const MAX_PLUGIN_METADATA_BYTES = 4 * 1024 * 1024 const WSL_METADATA_TIMEOUT_MS = 5_000 @@ -39,17 +43,26 @@ export function parseWslClaudePluginMetadataOutput( fileCount: number ): (string | null)[] { const contents = Array(fileCount).fill(null) - const fields = output.split('\0') - let index = 0 - while (index < fields.length && fields[index]) { - const kind = fields[index++] - const fileIndex = Number.parseInt(fields[index++] ?? '', 10) - const exists = fields[index++] === '1' - const encoded = fields[index++] + const cursor: WslSkillProtocolFieldCursor = { offset: 0 } + const readField = (): string => + readWslSkillProtocolField( + output, + cursor, + 'WSL Claude plugin metadata returned an incomplete response.' + ) + while (cursor.offset < output.length) { + // Why: cursor reads keep delimiter-heavy subprocess output from amplifying into a huge array. + const kind = readField() + if (!kind) { + break + } + const fileIndex = Number.parseInt(readField(), 10) + const exists = readField() === '1' + const encoded = readField() if (kind !== 'F' || !Number.isInteger(fileIndex) || fileIndex < 0 || fileIndex >= fileCount) { throw new Error('WSL Claude plugin metadata returned an invalid response.') } - if (exists && encoded !== undefined) { + if (exists) { contents[fileIndex] = Buffer.from(encoded, 'base64').toString('utf8') } } diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index a5d113aebe9..89fed1befe2 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -328,4 +328,25 @@ describe('skill discovery', () => { expect(result.skills.map((skill) => skill.name)).not.toContain('Too Deep') }) + + it('rejects pathological retained skill metadata instead of growing the result unboundedly', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const skillsRoot = join(home, '.agents', 'skills') + const description = 'x'.repeat(150_000) + await Promise.all( + Array.from({ length: 16 }, async (_, index) => { + const skillDir = join(skillsRoot, `large-${index}`) + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, 'SKILL.md'), + `---\nname: large-${index}\ndescription: ${description}\n---\n` + ) + }) + ) + + await expect( + discoverSkills({ homeDir: home, cwd: join(root, 'missing-cwd'), repos: [] }) + ).rejects.toThrow(/result is too large/) + }) }) diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index 00edfc8e705..e8965d6e1b2 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -1,5 +1,4 @@ -import type { Dirent } from 'node:fs' -import { open, readdir, realpath, stat } from 'node:fs/promises' +import { open, opendir, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { summarizeSkillMarkdown } from '../../shared/skill-metadata' @@ -18,6 +17,15 @@ import { type SkillScanRoot } from './skill-discovery-sources' import { discoverClaudePluginSkillSources } from './claude-plugin-skill-sources' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { + MAX_CONCURRENT_SKILL_DISCOVERY_CANDIDATES, + MAX_CONCURRENT_SKILL_DISCOVERY_ROOTS, + MAX_SKILL_PACKAGE_DIRECTORIES, + MAX_SKILL_PACKAGE_ENTRIES, + SkillDiscoveryBudget, + SkillDiscoveryLimitError +} from './skill-discovery-limits' export { buildSkillDiscoverySources } from './skill-discovery-sources' @@ -46,13 +54,18 @@ function isWithinDepth(rootPath: string, childPath: string, maxDepth: number): b return rel.split(sep).length <= maxDepth } -async function findSkillFiles(rootPath: string, maxDepth: number): Promise { +async function findSkillFiles( + rootPath: string, + maxDepth: number, + budget: SkillDiscoveryBudget +): Promise { const out: string[] = [] const visitedDirectoryPaths = new Set() async function visit(dirPath: string): Promise { if (!isWithinDepth(rootPath, dirPath, maxDepth)) { return } + budget.visitDirectory() let resolvedDirPath: string try { resolvedDirPath = await realpath(dirPath) @@ -64,45 +77,46 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise null) + if (!directory) { return } - for (const entry of entries) { - const entryPath = join(dirPath, entry.name) - if (entry.name === SKILL_FILE_NAME) { - if (entry.isFile()) { - out.push(entryPath) + try { + for await (const entry of directory) { + budget.visitEntry() + const entryPath = join(dirPath, entry.name) + if (entry.name === SKILL_FILE_NAME) { + if (entry.isFile()) { + budget.admitCandidate() + out.push(entryPath) + continue + } + if (entry.isSymbolicLink()) { + const linkedStat = await stat(entryPath).catch(() => null) + if (linkedStat?.isFile()) { + budget.admitCandidate() + out.push(entryPath) + } + } + continue + } + if (entry.isDirectory()) { + await visit(entryPath) continue } if (entry.isSymbolicLink()) { - try { - if ((await stat(entryPath)).isFile()) { - out.push(entryPath) - } - } catch { - // Broken links are not valid skill files. - } - } - continue - } - if (entry.isDirectory()) { - await visit(entryPath) - continue - } - if (entry.isSymbolicLink()) { - // Why: users commonly symlink agent skill dirs across providers; follow - // directory links but guard by realpath so recursive links cannot loop. - try { - if ((await stat(entryPath)).isDirectory()) { + // Why: users commonly symlink agent skill dirs across providers; follow + // directory links but guard by realpath so recursive links cannot loop. + const linkedStat = await stat(entryPath).catch(() => null) + if (linkedStat?.isDirectory()) { await visit(entryPath) } - } catch { - // Broken links are not valid skill directories. } } + } catch (error) { + if (error instanceof SkillDiscoveryLimitError) { + throw error + } } } await visit(rootPath) @@ -111,9 +125,16 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise { let count = 0 + let entriesVisited = 0 + let stoppedEarly = false const visitedDirectoryPaths = new Set() async function visit(currentPath: string): Promise { - if (count >= MAX_SKILL_FILES) { + if ( + stoppedEarly || + count >= MAX_SKILL_FILES || + visitedDirectoryPaths.size >= MAX_SKILL_PACKAGE_DIRECTORIES + ) { + stoppedEarly = true return } let resolvedPath: string @@ -127,30 +148,34 @@ async function countFiles(dirPath: string): Promise { } visitedDirectoryPaths.add(resolvedPath) - let entries: Dirent[] - try { - entries = await readdir(currentPath, { withFileTypes: true }) - } catch { + const directory = await opendir(currentPath).catch(() => null) + if (!directory) { return } - for (const entry of entries) { - if (count >= MAX_SKILL_FILES) { - return - } - const entryPath = join(currentPath, entry.name) - if (entry.isFile()) { - count += 1 - } else if (entry.isDirectory()) { - await visit(entryPath) - } else if (entry.isSymbolicLink()) { - try { - if ((await stat(entryPath)).isFile()) { - count += 1 + try { + for await (const entry of directory) { + entriesVisited += 1 + if (count >= MAX_SKILL_FILES || entriesVisited > MAX_SKILL_PACKAGE_ENTRIES) { + stoppedEarly = true + return + } + const entryPath = join(currentPath, entry.name) + if (entry.isFile()) { + count += 1 + } else if (entry.isDirectory()) { + await visit(entryPath) + } else if (entry.isSymbolicLink()) { + try { + if ((await stat(entryPath)).isFile()) { + count += 1 + } + } catch { + // Broken links do not contribute to the skill package file count. } - } catch { - // Broken links do not contribute to the skill package file count. } } + } catch { + // Preserve the partial count when a directory changes during enumeration. } } await visit(dirPath) @@ -184,11 +209,16 @@ async function readSkillSummary(skillFilePath: string): Promise<{ type ScannedSkill = DiscoveredSkill & { canonicalSkillFilePath: string } -async function scanRoot(root: SkillScanRoot): Promise { +async function scanRoot( + root: SkillScanRoot, + budget: SkillDiscoveryBudget +): Promise { const maxDepth = root.sourceKind === 'plugin' ? 9 : 4 - const skillFiles = await findSkillFiles(root.path, maxDepth) - const skills = await Promise.all( - skillFiles.map(async (skillFilePath): Promise => { + const skillFiles = await findSkillFiles(root.path, maxDepth, budget) + const skills = await mapWithConcurrency( + skillFiles, + MAX_CONCURRENT_SKILL_DISCOVERY_CANDIDATES, + async (skillFilePath): Promise => { // Why: path identity belongs to the scanning host; canonicalizing before // returning prevents symlinked roots from becoming duplicate picker rows. const canonicalSkillFilePath = await realpath(skillFilePath).catch(() => skillFilePath) @@ -198,7 +228,7 @@ async function scanRoot(root: SkillScanRoot): Promise { return null } const sourceKind = sourceKindForSkill(root, skillFilePath, { relative, sep }) - return { + const skill = { id: stablePathId(canonicalSkillFilePath), name: summary.name ?? basename(directoryPath), description: summary.description, @@ -215,7 +245,9 @@ async function scanRoot(root: SkillScanRoot): Promise { updatedAt: summary.updatedAt, canonicalSkillFilePath } satisfies ScannedSkill - }) + budget.retainSkill(skill) + return skill + } ) return skills.filter((skill): skill is ScannedSkill => skill !== null) } @@ -235,54 +267,58 @@ export async function discoverSkills(args: { ? await discoverClaudePluginSkillSources({ homeDir, cwd: args.cwd }) : []) ] - const sources: SkillDiscoverySource[] = [] - const skillGroups = await Promise.all( - roots.map(async (root) => { + const budget = new SkillDiscoveryBudget(roots) + const scannedRoots = await mapWithConcurrency( + roots, + MAX_CONCURRENT_SKILL_DISCOVERY_ROOTS, + async (root) => { const exists = await pathExists(root.path) - sources.push({ + const source: SkillDiscoverySource = { ...root, providers: [...root.providers], exists, skippedReason: exists ? undefined : 'missing' - }) - if (!exists) { - return [] } - return scanRoot(root) - }) + if (!exists) { + return { source, skills: [] } + } + return { source, skills: await scanRoot(root, budget) } + } ) const seen = new Map() - for (const skill of skillGroups.flat()) { - // Why: overlapping repo/cwd roots and symlinked provider homes can reach - // the same file. Keep the first source's higher-level scope identity, but - // record every contributing root so per-agent visibility survives dedup. - const existing = seen.get(skill.canonicalSkillFilePath) - if (existing) { - if (existing.rootPaths && !existing.rootPaths.includes(skill.rootPath)) { - existing.rootPaths.push(skill.rootPath) - } - // Why: providers is per-agent visibility just like rootPaths; keeping only - // the first root's tags makes a shared/symlinked skill under-report which - // agents can see it on the Settings provider badges/filter. Reassign a - // fresh array — `providers` aliases the scan root's array, so pushing in - // place would mutate the root and every sibling skill/source sharing it. - const mergedProviders = [...existing.providers] - for (const provider of skill.providers) { - if (!mergedProviders.includes(provider)) { - mergedProviders.push(provider) + for (const group of scannedRoots) { + for (const skill of group.skills) { + // Why: overlapping repo/cwd roots and symlinked provider homes can reach + // the same file. Keep the first source's higher-level scope identity, but + // record every contributing root so per-agent visibility survives dedup. + const existing = seen.get(skill.canonicalSkillFilePath) + if (existing) { + if (existing.rootPaths && !existing.rootPaths.includes(skill.rootPath)) { + existing.rootPaths.push(skill.rootPath) } + // Why: providers is per-agent visibility just like rootPaths; keeping only + // the first root's tags makes a shared/symlinked skill under-report which + // agents can see it on the Settings provider badges/filter. Reassign a + // fresh array — `providers` aliases the scan root's array, so pushing in + // place would mutate the root and every sibling skill/source sharing it. + const mergedProviders = [...existing.providers] + for (const provider of skill.providers) { + if (!mergedProviders.includes(provider)) { + mergedProviders.push(provider) + } + } + existing.providers = mergedProviders + continue } - existing.providers = mergedProviders - continue + const { canonicalSkillFilePath, ...publicSkill } = skill + seen.set(canonicalSkillFilePath, { ...publicSkill, rootPaths: [skill.rootPath] }) } - const { canonicalSkillFilePath, ...publicSkill } = skill - seen.set(canonicalSkillFilePath, { ...publicSkill, rootPaths: [skill.rootPath] }) } return { skills: Array.from(seen.values()).sort(compareSkills), - sources: sources.sort((a, b) => - a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) - ), + sources: scannedRoots + .map(({ source }) => source) + .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })), scannedAt: Date.now() } } diff --git a/src/main/skills/skill-discovery-limits.test.ts b/src/main/skills/skill-discovery-limits.test.ts new file mode 100644 index 00000000000..659b0383db1 --- /dev/null +++ b/src/main/skills/skill-discovery-limits.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import type { DiscoveredSkill } from '../../shared/skills' +import type { SkillScanRoot } from './skill-discovery-sources' +import { + MAX_SKILL_DISCOVERY_CANDIDATES, + MAX_SKILL_DISCOVERY_RESULT_BYTES, + MAX_SKILL_DISCOVERY_ROOTS, + SkillDiscoveryBudget, + WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, + assertSkillDiscoveryOutputWithinLimit +} from './skill-discovery-limits' + +function root(index: number): SkillScanRoot { + return { + id: `root-${index}`, + label: `Root ${index}`, + path: `/skills/${index}`, + sourceKind: 'home', + providers: ['agent-skills'], + owner: null + } +} + +function skill(description: string): DiscoveredSkill { + return { + id: 'skill-id', + name: 'Skill', + description, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: '/skills', + directoryPath: '/skills/example', + skillFilePath: '/skills/example/SKILL.md', + installed: true, + fileCount: 1, + updatedAt: null + } +} + +describe('installed-skill discovery limits', () => { + it('rejects root and candidate counts beyond their fixed budgets', () => { + expect( + () => + new SkillDiscoveryBudget( + Array.from({ length: MAX_SKILL_DISCOVERY_ROOTS + 1 }, (_, index) => root(index)) + ) + ).toThrow(/too many roots/) + + const budget = new SkillDiscoveryBudget([root(0)]) + for (let index = 0; index < MAX_SKILL_DISCOVERY_CANDIDATES; index += 1) { + budget.admitCandidate() + } + expect(() => budget.admitCandidate()).toThrow(/too many skills/) + }) + + it('rejects retained result text and WSL output beyond their byte budgets', () => { + const budget = new SkillDiscoveryBudget([root(0)]) + expect(() => + budget.retainSkill(skill('x'.repeat(Math.floor(MAX_SKILL_DISCOVERY_RESULT_BYTES / 2)))) + ).toThrow(/result is too large/) + + expect(() => + assertSkillDiscoveryOutputWithinLimit('x'.repeat(WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES + 1)) + ).toThrow(/output is too large/) + }) +}) diff --git a/src/main/skills/skill-discovery-limits.ts b/src/main/skills/skill-discovery-limits.ts new file mode 100644 index 00000000000..038bf2f6ba7 --- /dev/null +++ b/src/main/skills/skill-discovery-limits.ts @@ -0,0 +1,119 @@ +import type { DiscoveredSkill } from '../../shared/skills' +import type { SkillScanRoot } from './skill-discovery-sources' + +export const MAX_SKILL_DISCOVERY_ROOTS = 512 +export const MAX_SKILL_DISCOVERY_CANDIDATES = 1_024 +export const MAX_SKILL_DISCOVERY_TRAVERSED_DIRECTORIES = 20_000 +export const MAX_SKILL_DISCOVERY_TRAVERSED_ENTRIES = 100_000 +export const MAX_SKILL_DISCOVERY_RESULT_BYTES = 4 * 1024 * 1024 +export const MAX_CONCURRENT_SKILL_DISCOVERY_ROOTS = 4 +export const MAX_CONCURRENT_SKILL_DISCOVERY_CANDIDATES = 4 +export const MAX_SKILL_PACKAGE_DIRECTORIES = 512 +export const MAX_SKILL_PACKAGE_ENTRIES = 10_000 +export const WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024 + +export class SkillDiscoveryLimitError extends Error { + constructor(message: string) { + super(message) + this.name = 'SkillDiscoveryLimitError' + } +} + +export function assertSkillDiscoveryRootsWithinLimit(roots: readonly SkillScanRoot[]): void { + if (roots.length > MAX_SKILL_DISCOVERY_ROOTS) { + throw new SkillDiscoveryLimitError( + `Installed-skill discovery has too many roots (${roots.length}; max ${MAX_SKILL_DISCOVERY_ROOTS}).` + ) + } +} + +function stringBytes(value: string | null | undefined): number { + return (value?.length ?? 0) * 2 +} + +export function estimateDiscoveredSkillBytes(skill: DiscoveredSkill): number { + return ( + 256 + + stringBytes(skill.id) + + stringBytes(skill.name) + + stringBytes(skill.description) + + stringBytes(skill.sourceLabel) + + stringBytes(skill.rootPath) + + stringBytes(skill.directoryPath) + + stringBytes(skill.skillFilePath) + + skill.providers.reduce((bytes, provider) => bytes + stringBytes(provider), 0) + + (skill.rootPaths ?? []).reduce((bytes, rootPath) => bytes + stringBytes(rootPath), 0) + ) +} + +function estimateSkillSourceBytes(source: SkillScanRoot): number { + return ( + 192 + + stringBytes(source.id) + + stringBytes(source.label) + + stringBytes(source.path) + + source.providers.reduce((bytes, provider) => bytes + stringBytes(provider), 0) + ) +} + +export class SkillDiscoveryBudget { + private candidates = 0 + private directories = 0 + private entries = 0 + private retainedBytes = 0 + + constructor(roots: readonly SkillScanRoot[]) { + assertSkillDiscoveryRootsWithinLimit(roots) + for (const root of roots) { + this.retainBytes(estimateSkillSourceBytes(root)) + } + } + + visitDirectory(): void { + this.directories += 1 + if (this.directories > MAX_SKILL_DISCOVERY_TRAVERSED_DIRECTORIES) { + throw new SkillDiscoveryLimitError( + `Installed-skill discovery visited too many directories (max ${MAX_SKILL_DISCOVERY_TRAVERSED_DIRECTORIES}).` + ) + } + } + + visitEntry(): void { + this.entries += 1 + if (this.entries > MAX_SKILL_DISCOVERY_TRAVERSED_ENTRIES) { + throw new SkillDiscoveryLimitError( + `Installed-skill discovery visited too many entries (max ${MAX_SKILL_DISCOVERY_TRAVERSED_ENTRIES}).` + ) + } + } + + admitCandidate(): void { + this.candidates += 1 + if (this.candidates > MAX_SKILL_DISCOVERY_CANDIDATES) { + throw new SkillDiscoveryLimitError( + `Installed-skill discovery found too many skills (max ${MAX_SKILL_DISCOVERY_CANDIDATES}).` + ) + } + } + + retainSkill(skill: DiscoveredSkill): void { + this.retainBytes(estimateDiscoveredSkillBytes(skill)) + } + + private retainBytes(bytes: number): void { + this.retainedBytes += bytes + if (this.retainedBytes > MAX_SKILL_DISCOVERY_RESULT_BYTES) { + throw new SkillDiscoveryLimitError( + `Installed-skill discovery result is too large (max ${MAX_SKILL_DISCOVERY_RESULT_BYTES} bytes).` + ) + } + } +} + +export function assertSkillDiscoveryOutputWithinLimit(output: string): void { + if (Buffer.byteLength(output, 'utf8') > WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES) { + throw new SkillDiscoveryLimitError( + `WSL installed-skill discovery output is too large (max ${WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES} bytes).` + ) + } +} diff --git a/src/main/skills/skill-discovery-wsl.test.ts b/src/main/skills/skill-discovery-wsl.test.ts index 621b9f4c0a3..81e6c260316 100644 --- a/src/main/skills/skill-discovery-wsl.test.ts +++ b/src/main/skills/skill-discovery-wsl.test.ts @@ -85,12 +85,29 @@ describe('WSL skill discovery', () => { expect(script).toContain('find -L "$root_path"') expect(script).toContain('realpath -- "$skill_file"') expect(script).toContain('head -c 262144 -- "$skill_file"') + expect(script).toContain('skill_count=$((skill_count + 1))') + expect(script).toContain("printf '%s\\0%s\\0' E skill-limit") expect(script).toContain(`'/work/alice'\\''s project/.agents/skills'`) }) + it('rejects an explicit distro-side candidate-limit marker', () => { + expect(() => parseWslSkillDiscoveryOutput(record('E', 'skill-limit'), [homeRoot])).toThrow( + /too many skills/ + ) + }) + it('rejects malformed host responses instead of reporting an empty scan', () => { expect(() => parseWslSkillDiscoveryOutput(record('S', '9'), [homeRoot])).toThrow( 'unknown source' ) }) + + it('parses delimiter-heavy output without materializing a field array', () => { + const result = parseWslSkillDiscoveryOutput('\0'.repeat(1_000_000), [homeRoot], 42) + + expect(result.skills).toEqual([]) + expect(result.sources).toEqual([ + expect.objectContaining({ id: 'home-codex', exists: false, skippedReason: 'missing' }) + ]) + }) }) diff --git a/src/main/skills/skill-discovery-wsl.ts b/src/main/skills/skill-discovery-wsl.ts index c4f553225ae..189d4fa11af 100644 --- a/src/main/skills/skill-discovery-wsl.ts +++ b/src/main/skills/skill-discovery-wsl.ts @@ -16,16 +16,29 @@ import { type SkillScanRoot } from './skill-discovery-sources' import { discoverClaudePluginSkillSourcesInWsl } from './claude-plugin-skill-sources-wsl' +import { + MAX_SKILL_DISCOVERY_CANDIDATES, + SkillDiscoveryBudget, + SkillDiscoveryLimitError, + WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, + assertSkillDiscoveryOutputWithinLimit, + assertSkillDiscoveryRootsWithinLimit +} from './skill-discovery-limits' +import { + readWslSkillProtocolField, + type WslSkillProtocolFieldCursor +} from './wsl-skill-protocol-fields' const MAX_MARKDOWN_BYTES = 256 * 1024 const MAX_PACKAGE_FILES = 200 const WSL_SCAN_TIMEOUT_MS = 10_000 -const WSL_SCAN_MAX_BUFFER_BYTES = 128 * 1024 * 1024 export function buildWslSkillDiscoveryCommand(roots: readonly SkillScanRoot[]): string { + assertSkillDiscoveryRootsWithinLimit(roots) const lines = [ 'set -u', 'set -o pipefail', + 'skill_count=0', 'scan_root() {', ' root_index=$1', ' root_path=$2', @@ -40,6 +53,11 @@ export function buildWslSkillDiscoveryCommand(roots: readonly SkillScanRoot[]): ` directory_path=\${skill_file%/*}`, ` updated_at=$(stat -c '%Y' -- "$skill_file" 2>/dev/null || true)`, ` encoded_markdown=$(head -c ${MAX_MARKDOWN_BYTES} -- "$skill_file" 2>/dev/null | base64 | tr -d '\\n') || continue`, + ' skill_count=$((skill_count + 1))', + ` if [ "$skill_count" -gt ${MAX_SKILL_DISCOVERY_CANDIDATES} ]; then`, + ` printf '%s\\0%s\\0' E skill-limit`, + ' exit 0', + ' fi', ' file_count=0', ` while IFS= read -r -d '' package_file; do`, ' file_count=$((file_count + 1))', @@ -65,7 +83,7 @@ function executeWslSkillDiscovery(distro: string, command: string): Promise() const skillsByCanonicalPath = new Map() - let index = 0 - while (index < fields.length && fields[index]) { - const recordKind = fields[index++] - const rootIndex = Number.parseInt(readProtocolField(fields, index++), 10) + const cursor: WslSkillProtocolFieldCursor = { offset: 0 } + while (cursor.offset < output.length) { + // Why: cursor reads avoid turning delimiter-heavy bounded output into millions of string slots. + const recordKind = readProtocolField(output, cursor) + if (!recordKind) { + break + } + if (recordKind === 'E') { + const reason = readProtocolField(output, cursor) + throw new SkillDiscoveryLimitError( + reason === 'skill-limit' + ? `WSL installed-skill discovery found too many skills (max ${MAX_SKILL_DISCOVERY_CANDIDATES}).` + : 'WSL installed-skill discovery exceeded a safety limit.' + ) + } + const rootIndex = Number.parseInt(readProtocolField(output, cursor), 10) const root = roots[rootIndex] if (!root) { throw new Error('WSL skill discovery returned an unknown source.') } if (recordKind === 'R') { - rootExists.set(rootIndex, readProtocolField(fields, index++) === '1') + rootExists.set(rootIndex, readProtocolField(output, cursor) === '1') continue } if (recordKind !== 'S') { throw new Error('WSL skill discovery returned an invalid response.') } + budget.admitCandidate() - const skillFilePath = readProtocolField(fields, index++) - const canonicalSkillFilePath = readProtocolField(fields, index++) - const updatedAtSeconds = Number.parseInt(readProtocolField(fields, index++), 10) - const fileCount = Number.parseInt(readProtocolField(fields, index++), 10) - const markdown = Buffer.from(readProtocolField(fields, index++), 'base64').toString('utf8') + const skillFilePath = readProtocolField(output, cursor) + const canonicalSkillFilePath = readProtocolField(output, cursor) + const updatedAtSeconds = Number.parseInt(readProtocolField(output, cursor), 10) + const fileCount = Number.parseInt(readProtocolField(output, cursor), 10) + const markdown = Buffer.from(readProtocolField(output, cursor), 'base64').toString('utf8') const existing = skillsByCanonicalPath.get(canonicalSkillFilePath) if (existing) { // Why: dedup keeps one row, but every contributing root must survive so @@ -139,7 +171,7 @@ export function parseWslSkillDiscoveryOutput( const directoryPath = pathPosix.dirname(skillFilePath) const summary = summarizeSkillMarkdown(markdown) const sourceKind = sourceKindForSkill(root, skillFilePath, pathPosix) - skillsByCanonicalPath.set(canonicalSkillFilePath, { + const skill: DiscoveredSkill = { id: stablePathId(canonicalSkillFilePath), name: summary.name ?? pathPosix.basename(directoryPath), description: summary.description, @@ -155,7 +187,9 @@ export function parseWslSkillDiscoveryOutput( installed: true, fileCount: Number.isFinite(fileCount) ? fileCount : 0, updatedAt: Number.isFinite(updatedAtSeconds) ? updatedAtSeconds * 1000 : null - }) + } + budget.retainSkill(skill) + skillsByCanonicalPath.set(canonicalSkillFilePath, skill) } const sources: SkillDiscoverySource[] = roots.map((root, rootIndex) => { diff --git a/src/main/skills/wsl-skill-protocol-fields.ts b/src/main/skills/wsl-skill-protocol-fields.ts new file mode 100644 index 00000000000..4af7bde4aea --- /dev/null +++ b/src/main/skills/wsl-skill-protocol-fields.ts @@ -0,0 +1,20 @@ +export type WslSkillProtocolFieldCursor = { offset: number } + +export function readWslSkillProtocolField( + output: string, + cursor: WslSkillProtocolFieldCursor, + incompleteMessage: string +): string { + if (cursor.offset >= output.length) { + throw new Error(incompleteMessage) + } + const end = output.indexOf('\0', cursor.offset) + if (end === -1) { + const value = output.slice(cursor.offset) + cursor.offset = output.length + return value + } + const value = output.slice(cursor.offset, end) + cursor.offset = end + 1 + return value +} diff --git a/src/main/source-control/hosted-review-api-request.ts b/src/main/source-control/hosted-review-api-request.ts index dfdb0dbad70..3c1582bd616 100644 --- a/src/main/source-control/hosted-review-api-request.ts +++ b/src/main/source-control/hosted-review-api-request.ts @@ -1,3 +1,8 @@ +import { + readFetchResponseJsonWithinLimit, + readFetchResponseTextWithinLimit +} from '../lib/fetch-response-body' + export class HostedReviewApiRequestError extends Error { readonly status: number | null readonly timedOut: boolean @@ -12,7 +17,7 @@ export class HostedReviewApiRequestError extends Error { async function readResponseText(response: Response): Promise { try { - return await response.text() + return await readFetchResponseTextWithinLimit(response) } catch { return '' } @@ -31,7 +36,7 @@ export async function requestHostedReviewJson( status: response.status }) } - return (await response.json()) as T + return await readFetchResponseJsonWithinLimit(response) } catch (error) { if (error instanceof HostedReviewApiRequestError) { throw error diff --git a/src/main/source-control/pull-request-template.test.ts b/src/main/source-control/pull-request-template.test.ts new file mode 100644 index 00000000000..5b0bf33fcda --- /dev/null +++ b/src/main/source-control/pull-request-template.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MAX_HOSTED_REVIEW_TEMPLATE_BYTES, readHostedReviewTemplate } from './pull-request-template' + +describe('readHostedReviewTemplate', () => { + const roots: string[] = [] + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + + async function makeRepo(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-review-template-')) + roots.push(root) + await mkdir(join(root, '.github'), { recursive: true }) + return root + } + + it('preserves the first conventional template under the limit', async () => { + const repoPath = await makeRepo() + await writeFile(join(repoPath, '.github', 'pull_request_template.md'), 'normal template\n') + await writeFile(join(repoPath, 'PULL_REQUEST_TEMPLATE.md'), 'fallback\n') + + await expect(readHostedReviewTemplate(repoPath)).resolves.toBe('normal template\n') + }) + + it('skips an oversized sparse template and reads the next candidate', async () => { + const repoPath = await makeRepo() + const oversizedPath = join(repoPath, '.github', 'pull_request_template.md') + await writeFile(oversizedPath, 'x') + await truncate(oversizedPath, MAX_HOSTED_REVIEW_TEMPLATE_BYTES + 1) + await writeFile(join(repoPath, '.github', 'PULL_REQUEST_TEMPLATE.md'), 'bounded fallback\n') + + await expect(readHostedReviewTemplate(repoPath)).resolves.toBe('bounded fallback\n') + }) + + it('accepts a template exactly at the byte limit', async () => { + const repoPath = await makeRepo() + const body = 'a'.repeat(MAX_HOSTED_REVIEW_TEMPLATE_BYTES) + await writeFile(join(repoPath, '.github', 'pull_request_template.md'), body) + + await expect(readHostedReviewTemplate(repoPath)).resolves.toHaveLength( + MAX_HOSTED_REVIEW_TEMPLATE_BYTES + ) + }) +}) diff --git a/src/main/source-control/pull-request-template.ts b/src/main/source-control/pull-request-template.ts index e5f474a4958..295d54e5ca7 100644 --- a/src/main/source-control/pull-request-template.ts +++ b/src/main/source-control/pull-request-template.ts @@ -1,8 +1,10 @@ -import { readFile } from 'node:fs/promises' import { join } from 'node:path' import type { HostedReviewProvider } from '../../shared/hosted-review' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' +import { readNodeFileWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' + +export const MAX_HOSTED_REVIEW_TEMPLATE_BYTES = 1024 * 1024 const PULL_REQUEST_TEMPLATE_CANDIDATES = [ '.github/pull_request_template.md', @@ -28,6 +30,11 @@ function getTemplateCandidates(provider?: HostedReviewProvider | null): string[] if (provider === 'gitlab') { return [...MERGE_REQUEST_TEMPLATE_CANDIDATES, ...PULL_REQUEST_TEMPLATE_CANDIDATES] } + if (provider === 'github') { + return PULL_REQUEST_TEMPLATE_CANDIDATES.filter( + (candidate) => !candidate.startsWith('.azuredevops/') && !candidate.startsWith('.gitea/') + ) + } return PULL_REQUEST_TEMPLATE_CANDIDATES } @@ -38,27 +45,54 @@ export async function readHostedPullRequestTemplate( return readHostedReviewTemplate(repoPath, connectionId) } +export async function readGitLabMergeRequestTemplate( + repoPath: string, + connectionId?: string | null +): Promise { + return readHostedReviewTemplateCandidates( + repoPath, + connectionId, + MERGE_REQUEST_TEMPLATE_CANDIDATES + ) +} + export async function readHostedReviewTemplate( repoPath: string, connectionId?: string | null, provider?: HostedReviewProvider | null +): Promise { + return readHostedReviewTemplateCandidates(repoPath, connectionId, getTemplateCandidates(provider)) +} + +async function readHostedReviewTemplateCandidates( + repoPath: string, + connectionId: string | null | undefined, + candidates: readonly string[] ): Promise { const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined if (connectionId && !remoteProvider) { return '' } - for (const relativeCandidate of getTemplateCandidates(provider)) { + for (const relativeCandidate of candidates) { try { if (remoteProvider) { const result = await remoteProvider.readFile( joinWorktreeRelativePath(repoPath, relativeCandidate) ) - if (result.isBinary) { + if ( + result.isBinary || + Buffer.byteLength(result.content, 'utf8') > MAX_HOSTED_REVIEW_TEMPLATE_BYTES + ) { continue } return result.content } - return await readFile(join(repoPath, relativeCandidate), 'utf8') + return ( + await readNodeFileWithinLimit( + join(repoPath, relativeCandidate), + MAX_HOSTED_REVIEW_TEMPLATE_BYTES + ) + ).buffer.toString('utf8') } catch { // Try the next conventional hosted-review template path. } diff --git a/src/main/source-control/repository-ref-cache.test.ts b/src/main/source-control/repository-ref-cache.test.ts new file mode 100644 index 00000000000..41206fe10ad --- /dev/null +++ b/src/main/source-control/repository-ref-cache.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + buildRepositoryRefCacheKey, + REPOSITORY_REF_CACHE_KEY_MAX_BYTES, + REPOSITORY_REF_CACHE_MAX_ENTRIES, + REPOSITORY_REF_CACHE_VALUE_MAX_BYTES, + RepositoryRefCache +} from './repository-ref-cache' + +describe('repository ref cache bounds', () => { + it('admits an exact-boundary UTF-8 key and rejects one byte over', () => { + expect( + buildRepositoryRefCacheKey(['a'.repeat(REPOSITORY_REF_CACHE_KEY_MAX_BYTES - 1), '']) + ).not.toBeNull() + expect( + buildRepositoryRefCacheKey(['a'.repeat(REPOSITORY_REF_CACHE_KEY_MAX_BYTES), '']) + ).toBeNull() + }) + + it('measures multibyte keys by bytes', () => { + expect( + buildRepositoryRefCacheKey(['😀'.repeat(REPOSITORY_REF_CACHE_KEY_MAX_BYTES / 4)]) + ).not.toBeNull() + expect( + buildRepositoryRefCacheKey(['😀'.repeat(REPOSITORY_REF_CACHE_KEY_MAX_BYTES / 4 + 1)]) + ).toBeNull() + }) + + it('retains exact-boundary values and skips oversized values', () => { + const cache = new RepositoryRefCache<{ value: string }>() + const exactKey = buildRepositoryRefCacheKey(['exact']) + const oversizedKey = buildRepositoryRefCacheKey(['oversized']) + const exact = 'a'.repeat(REPOSITORY_REF_CACHE_VALUE_MAX_BYTES) + const oversized = 'a'.repeat(REPOSITORY_REF_CACHE_VALUE_MAX_BYTES + 1) + + cache.remember(exactKey, { value: exact }, [exact]) + cache.remember(oversizedKey, { value: oversized }, [oversized]) + + expect(cache.get(exactKey)).toEqual({ found: true, value: { value: exact } }) + expect(cache.get(oversizedKey)).toEqual({ found: false }) + }) + + it('bounds entry count and refreshes hits before LRU eviction', () => { + const cache = new RepositoryRefCache<{ value: number }>() + for (let index = 0; index < REPOSITORY_REF_CACHE_MAX_ENTRIES; index += 1) { + cache.remember(`key-${index}`, { value: index }, []) + } + expect(cache.get('key-0')).toEqual({ found: true, value: { value: 0 } }) + cache.remember('new-key', { value: -1 }, []) + + expect(cache.size).toBe(REPOSITORY_REF_CACHE_MAX_ENTRIES) + expect(cache.get('key-0').found).toBe(true) + expect(cache.get('key-1')).toEqual({ found: false }) + }) + + it('never retains an inadmissible key', () => { + const cache = new RepositoryRefCache<{ value: number }>() + cache.remember(null, { value: 1 }, []) + expect(cache.size).toBe(0) + }) +}) diff --git a/src/main/source-control/repository-ref-cache.ts b/src/main/source-control/repository-ref-cache.ts new file mode 100644 index 00000000000..fcaf5f08e85 --- /dev/null +++ b/src/main/source-control/repository-ref-cache.ts @@ -0,0 +1,71 @@ +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' + +export const REPOSITORY_REF_CACHE_MAX_ENTRIES = 512 +export const REPOSITORY_REF_CACHE_KEY_MAX_BYTES = 4 * 1024 +export const REPOSITORY_REF_CACHE_VALUE_MAX_BYTES = 16 * 1024 + +export type RepositoryRefCacheLookup = { found: true; value: T | null } | { found: false } + +export function buildRepositoryRefCacheKey(parts: readonly string[]): string | null { + let remainingBytes = REPOSITORY_REF_CACHE_KEY_MAX_BYTES - Math.max(0, parts.length - 1) + if (remainingBytes < 0) { + return null + } + for (const part of parts) { + const measured = measureUtf8ByteLength(part, { stopAfterBytes: remainingBytes }) + if (measured.exceededLimit) { + return null + } + remainingBytes -= measured.byteLength + } + return parts.join('\0') +} + +export class RepositoryRefCache { + private readonly entries = new Map() + + clear(): void { + this.entries.clear() + } + + get size(): number { + return this.entries.size + } + + get(cacheKey: string | null): RepositoryRefCacheLookup { + if (cacheKey === null || !this.entries.has(cacheKey)) { + return { found: false } + } + const value = this.entries.get(cacheKey) ?? null + this.entries.delete(cacheKey) + this.entries.set(cacheKey, value) + return { found: true, value } + } + + remember(cacheKey: string | null, value: T | null, retainedStrings: readonly string[]): void { + if (cacheKey === null || !fitsValueBudget(retainedStrings)) { + return + } + this.entries.delete(cacheKey) + this.entries.set(cacheKey, value) + while (this.entries.size > REPOSITORY_REF_CACHE_MAX_ENTRIES) { + const oldestKey = this.entries.keys().next().value + if (oldestKey === undefined) { + return + } + this.entries.delete(oldestKey) + } + } +} + +function fitsValueBudget(values: readonly string[]): boolean { + let remainingBytes = REPOSITORY_REF_CACHE_VALUE_MAX_BYTES + for (const value of values) { + const measured = measureUtf8ByteLength(value, { stopAfterBytes: remainingBytes }) + if (measured.exceededLimit) { + return false + } + remainingBytes -= measured.byteLength + } + return true +} diff --git a/src/main/speech/model-cache-path.ts b/src/main/speech/model-cache-path.ts index 82dd286f1dd..3361fbca05b 100644 --- a/src/main/speech/model-cache-path.ts +++ b/src/main/speech/model-cache-path.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' -import { cp, mkdir, readdir, rename, stat } from 'node:fs/promises' +import { cp, mkdir, opendir, rename, stat } from 'node:fs/promises' import { join, resolve } from 'node:path' export type SpeechModelCacheDir = { @@ -9,6 +9,72 @@ export type SpeechModelCacheDir = { } const WINDOWS_SAFE_CACHE_HASH_LENGTH = 16 +export const SPEECH_MODEL_CACHE_MIGRATION_MAX_ENTRIES = 16_384 +export const SPEECH_MODEL_CACHE_MIGRATION_MAX_DEPTH = 16 +export const SPEECH_MODEL_CACHE_MIGRATION_MAX_VISITED_PATH_BYTES = 16 * 1024 * 1024 + +export type SpeechModelCacheMigrationLimits = { + maxEntries: number + maxDepth: number + maxVisitedPathBytes: number +} + +class SpeechModelCacheMigrationBudget { + private entries = 0 + private visitedPathBytes = 0 + private readonly limits: SpeechModelCacheMigrationLimits + + constructor(requested: Partial) { + this.limits = { + maxEntries: resolveMigrationLimit( + requested.maxEntries, + SPEECH_MODEL_CACHE_MIGRATION_MAX_ENTRIES, + 'maxEntries' + ), + maxDepth: resolveMigrationLimit( + requested.maxDepth, + SPEECH_MODEL_CACHE_MIGRATION_MAX_DEPTH, + 'maxDepth' + ), + maxVisitedPathBytes: resolveMigrationLimit( + requested.maxVisitedPathBytes, + SPEECH_MODEL_CACHE_MIGRATION_MAX_VISITED_PATH_BYTES, + 'maxVisitedPathBytes' + ) + } + } + + claim(sourcePath: string, targetPath: string, depth: number): void { + if (depth > this.limits.maxDepth) { + throw new Error(`Speech model cache migration exceeded depth ${this.limits.maxDepth}`) + } + this.entries += 1 + if (this.entries > this.limits.maxEntries) { + throw new Error(`Speech model cache migration exceeded ${this.limits.maxEntries} entries`) + } + this.visitedPathBytes += + Buffer.byteLength(sourcePath, 'utf8') + Buffer.byteLength(targetPath, 'utf8') + if (this.visitedPathBytes > this.limits.maxVisitedPathBytes) { + throw new Error( + `Speech model cache migration exceeded ${this.limits.maxVisitedPathBytes} visited path bytes` + ) + } + } +} + +function resolveMigrationLimit( + requested: number | undefined, + maximum: number, + name: string +): number { + if (requested === undefined) { + return maximum + } + if (!Number.isSafeInteger(requested) || requested < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + return Math.min(requested, maximum) +} function hasNonAsciiCharacters(value: string): boolean { for (const character of value) { @@ -65,12 +131,24 @@ export function getSpeechModelCacheDirCandidates( return [...candidates, { modelsDir: requestedModelsDir, migrationSourceDir: null }] } -async function copyMissingCacheEntry(sourcePath: string, targetPath: string): Promise { +async function copyMissingCacheEntry( + sourcePath: string, + targetPath: string, + depth: number, + budget: SpeechModelCacheMigrationBudget +): Promise { + budget.claim(sourcePath, targetPath, depth) const sourceStat = await stat(sourcePath) if (sourceStat.isDirectory()) { await mkdir(targetPath, { recursive: true }) - for (const entry of await readdir(sourcePath, { withFileTypes: true })) { - await copyMissingCacheEntry(join(sourcePath, entry.name), join(targetPath, entry.name)) + const directory = await opendir(sourcePath, { bufferSize: 32 }) + for await (const entry of directory) { + await copyMissingCacheEntry( + join(sourcePath, entry.name), + join(targetPath, entry.name), + depth + 1, + budget + ) } return } @@ -88,15 +166,23 @@ async function copyMissingCacheEntry(sourcePath: string, targetPath: string): Pr export async function migrateSpeechModelCacheIfNeeded( sourceDir: string | null, - targetDir: string + targetDir: string, + limits: Partial = {} ): Promise { if (!sourceDir || resolve(sourceDir) === resolve(targetDir) || !existsSync(sourceDir)) { return } try { - for (const entry of await readdir(sourceDir, { withFileTypes: true })) { - await copyMissingCacheEntry(join(sourceDir, entry.name), join(targetDir, entry.name)) + const budget = new SpeechModelCacheMigrationBudget(limits) + const directory = await opendir(sourceDir, { bufferSize: 32 }) + for await (const entry of directory) { + await copyMissingCacheEntry( + join(sourceDir, entry.name), + join(targetDir, entry.name), + 1, + budget + ) } } catch (error) { console.warn('[speech] Failed to migrate speech model cache to ASCII path:', error) diff --git a/src/main/speech/model-manager.test.ts b/src/main/speech/model-manager.test.ts index 07d78749b08..97a18e83fbb 100644 --- a/src/main/speech/model-manager.test.ts +++ b/src/main/speech/model-manager.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SPEECH_MODEL_CATALOG } from './model-catalog' import { ModelManager } from './model-manager' +import { SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES } from './speech-model-extraction-stderr' const { hasOpenAiSpeechApiKeyMock, netRequestMock, spawnMock } = vi.hoisted(() => ({ hasOpenAiSpeechApiKeyMock: vi.fn(), @@ -452,4 +453,59 @@ describe('ModelManager', () => { rmSync(dir, { recursive: true, force: true }) } }) + + it('surfaces bounded prefix and tail evidence when tar stderr is oversized', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + try { + const handlers: Record void)[]> = { + close: [], + error: [] + } + const stderrHandlers: ((chunk: Buffer) => void)[] = [] + const child = { + stderr: { + on: vi.fn((_event: string, cb: (chunk: Buffer) => void) => { + stderrHandlers.push(cb) + return child.stderr + }), + off: vi.fn((_event: string, cb: (chunk: Buffer) => void) => { + const index = stderrHandlers.indexOf(cb) + if (index !== -1) { + stderrHandlers.splice(index, 1) + } + return child.stderr + }) + }, + kill: vi.fn(), + on: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }) + } + spawnMock.mockReturnValue(child) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const extraction = manager.extractArchive(join(dir, 'model.tar.bz2'), dir, 'm', () => false) + stderrHandlers[0]( + Buffer.concat([ + Buffer.from('PREFIX'), + Buffer.alloc(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES * 2, 0x78), + Buffer.from('TAIL') + ]) + ) + handlers.close[0](2) + + await expect(extraction).rejects.toThrow( + /tar exited with code 2: PREFIX[\s\S]+stderr bytes omitted[\s\S]+TAIL$/ + ) + expect(stderrHandlers).toHaveLength(0) + expect(handlers.close).toHaveLength(0) + expect(handlers.error).toHaveLength(0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 2b8bca8bbca..396fb146570 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -9,7 +9,7 @@ import { rmSync, statSync } from 'node:fs' -import { readdir, rm } from 'node:fs/promises' +import { rm } from 'node:fs/promises' import { createHash } from 'node:crypto' import { pipeline } from 'node:stream/promises' import { spawn } from 'node:child_process' @@ -26,6 +26,8 @@ import { migrateSpeechModelCacheIfNeeded, type SpeechModelCacheDir } from './model-cache-path' +import { findNestedSpeechModelDirectory } from './speech-model-directory-scanner' +import { SpeechModelExtractionStderr } from './speech-model-extraction-stderr' type DownloadHandle = { abort: () => void @@ -835,7 +837,7 @@ export class ModelManager { } ) - let stderr = '' + const stderr = new SpeechModelExtractionStderr() let settled = false let timeout: ReturnType | null = null let abortPoll: ReturnType | null = null @@ -864,7 +866,7 @@ export class ModelManager { reject(error) } const onStderrData = (chunk: Buffer): void => { - stderr += chunk.toString() + stderr.append(chunk) } const onClose = (code: number | null): void => { if (settled) { @@ -875,7 +877,7 @@ export class ModelManager { if (code === 0) { resolve() } else { - reject(new Error(`tar exited with code ${code}: ${stderr.slice(0, 500)}`)) + reject(new Error(`tar exited with code ${code}: ${stderr.errorEvidence()}`)) } } const onError = (err: Error): void => { @@ -902,22 +904,15 @@ export class ModelManager { if (!manifest.files) { return } - const entries = await readdir(modelDir, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isDirectory()) { - const nestedDir = join(modelDir, entry.name) - const nestedFiles = await readdir(nestedDir) - const hasExpected = manifest.files.some((f) => nestedFiles.includes(f)) - if (hasExpected) { - const { rename: fsRename } = await import('node:fs/promises') - for (const file of nestedFiles) { - await fsRename(join(nestedDir, file), join(modelDir, file)) - } - await rm(nestedDir, { recursive: true, force: true }) - return - } - } + const nested = await findNestedSpeechModelDirectory(modelDir, manifest.files) + if (!nested) { + return } + const { rename: fsRename } = await import('node:fs/promises') + for (const file of nested.entryNames) { + await fsRename(join(nested.directoryPath, file), join(modelDir, file)) + } + await rm(nested.directoryPath, { recursive: true, force: true }) } private cleanup(modelId: string, archivePath: string): void { diff --git a/src/main/speech/openai-transcription-client.test.ts b/src/main/speech/openai-transcription-client.test.ts index 8e20f593b08..bbe4da6576b 100644 --- a/src/main/speech/openai-transcription-client.test.ts +++ b/src/main/speech/openai-transcription-client.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { sanitizeOpenAiTranscriptionErrorMessage } from './openai-transcription-client' +import { + OpenAiTranscriptionSession, + sanitizeOpenAiTranscriptionErrorMessage +} from './openai-transcription-client' describe('sanitizeOpenAiTranscriptionErrorMessage', () => { it('does not expose the invalid OpenAI API key echoed by the provider', () => { @@ -17,4 +20,16 @@ describe('sanitizeOpenAiTranscriptionErrorMessage', () => { ) ).toBe('Request failed for [redacted] with Authorization: Bearer [redacted]') }) + + it('retains one growable sample buffer for adversarial one-sample feeds', () => { + const session = new OpenAiTranscriptionSession('openai-gpt-4o-mini-transcribe', () => 'key') + const sample = new Float32Array([0.25]) + + for (let index = 0; index < 100_000; index += 1) { + session.feedAudio(sample, 16_000) + } + + expect(Reflect.get(session, 'sampleCount')).toBe(100_000) + expect(Reflect.get(session, 'samples')).toBeInstanceOf(Float32Array) + }) }) diff --git a/src/main/speech/openai-transcription-client.ts b/src/main/speech/openai-transcription-client.ts index c61dc940f10..f8b6e9b5336 100644 --- a/src/main/speech/openai-transcription-client.ts +++ b/src/main/speech/openai-transcription-client.ts @@ -1,4 +1,5 @@ import { resampleToRate } from './stt-audio-resample' +import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' export const OPENAI_TRANSCRIPTION_MODEL_BY_ID: Record = { 'openai-gpt-4o-mini-transcribe': 'gpt-4o-mini-transcribe', @@ -8,6 +9,7 @@ export const OPENAI_TRANSCRIPTION_MODEL_BY_ID: Record = { const OPENAI_TRANSCRIPTION_URL = 'https://api.openai.com/v1/audio/transcriptions' const CLOUD_TRANSCRIPTION_SAMPLE_RATE = 16000 const MAX_CLOUD_AUDIO_SECONDS = 10 * 60 +const MAX_CLOUD_AUDIO_SAMPLES = CLOUD_TRANSCRIPTION_SAMPLE_RATE * MAX_CLOUD_AUDIO_SECONDS type OpenAiTranscriptionResponse = { text?: unknown @@ -29,8 +31,8 @@ export function sanitizeOpenAiTranscriptionErrorMessage(message: string): string return sanitized || 'OpenAI transcription request failed' } -function encodePcm16Wav(samples: Float32Array, sampleRate: number): Buffer { - const dataBytes = samples.length * 2 +function encodePcm16Wav(samples: Float32Array, sampleCount: number, sampleRate: number): Buffer { + const dataBytes = sampleCount * 2 const buffer = Buffer.alloc(44 + dataBytes) buffer.write('RIFF', 0) @@ -47,7 +49,7 @@ function encodePcm16Wav(samples: Float32Array, sampleRate: number): Buffer { buffer.write('data', 36) buffer.writeUInt32LE(dataBytes, 40) - for (let i = 0; i < samples.length; i += 1) { + for (let i = 0; i < sampleCount; i += 1) { const clamped = Math.max(-1, Math.min(1, samples[i])) const value = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff buffer.writeInt16LE(Math.round(value), 44 + i * 2) @@ -56,17 +58,6 @@ function encodePcm16Wav(samples: Float32Array, sampleRate: number): Buffer { return buffer } -function combineChunks(chunks: Float32Array[]): Float32Array { - const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0) - const combined = new Float32Array(totalLength) - let offset = 0 - for (const chunk of chunks) { - combined.set(chunk, offset) - offset += chunk.length - } - return combined -} - function parseOpenAiTranscriptionResponse(data: OpenAiTranscriptionResponse): string { if (typeof data.text === 'string') { return data.text.trim() @@ -78,8 +69,8 @@ function parseOpenAiTranscriptionResponse(data: OpenAiTranscriptionResponse): st } export class OpenAiTranscriptionSession { - private chunks: Float32Array[] = [] - private audioSeconds = 0 + private samples = new Float32Array(0) + private sampleCount = 0 constructor( private readonly modelId: string, @@ -88,15 +79,25 @@ export class OpenAiTranscriptionSession { feedAudio(samples: Float32Array, sampleRate: number): void { const normalized = resampleToRate(samples, sampleRate, CLOUD_TRANSCRIPTION_SAMPLE_RATE) - this.audioSeconds += normalized.length / CLOUD_TRANSCRIPTION_SAMPLE_RATE - if (this.audioSeconds > MAX_CLOUD_AUDIO_SECONDS) { + const nextSampleCount = this.sampleCount + normalized.length + if (nextSampleCount > MAX_CLOUD_AUDIO_SAMPLES) { throw new Error('Cloud transcription is limited to 10 minutes per dictation') } - this.chunks.push(new Float32Array(normalized)) + if (this.samples.length < nextSampleCount) { + const nextCapacity = Math.min( + MAX_CLOUD_AUDIO_SAMPLES, + Math.max(CLOUD_TRANSCRIPTION_SAMPLE_RATE, this.samples.length * 2, nextSampleCount) + ) + const next = new Float32Array(nextCapacity) + next.set(this.samples.subarray(0, this.sampleCount)) + this.samples = next + } + this.samples.set(normalized, this.sampleCount) + this.sampleCount = nextSampleCount } async finish(): Promise { - if (this.chunks.length === 0) { + if (this.sampleCount === 0) { return '' } @@ -105,9 +106,9 @@ export class OpenAiTranscriptionSession { throw new Error(`Unknown OpenAI transcription model: ${this.modelId}`) } - const audio = combineChunks(this.chunks) - this.chunks = [] - const wav = encodePcm16Wav(audio, CLOUD_TRANSCRIPTION_SAMPLE_RATE) + const wav = encodePcm16Wav(this.samples, this.sampleCount, CLOUD_TRANSCRIPTION_SAMPLE_RATE) + this.samples = new Float32Array(0) + this.sampleCount = 0 const form = new FormData() form.append('model', apiModel) form.append('response_format', 'json') @@ -123,7 +124,9 @@ export class OpenAiTranscriptionSession { body: form }) - const data = (await response.json().catch(() => ({}))) as OpenAiTranscriptionResponse + const data = await readFetchResponseJsonWithinLimit( + response + ).catch((): OpenAiTranscriptionResponse => ({})) if (!response.ok) { const message = typeof data.error?.message === 'string' diff --git a/src/main/speech/speech-model-directory-scanner.test.ts b/src/main/speech/speech-model-directory-scanner.test.ts new file mode 100644 index 00000000000..831b1e2dafb --- /dev/null +++ b/src/main/speech/speech-model-directory-scanner.test.ts @@ -0,0 +1,172 @@ +import type * as NodeFs from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { opendirMock, opendirSyncMock } = vi.hoisted(() => ({ + opendirMock: vi.fn(), + opendirSyncMock: vi.fn() +})) + +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + opendirSync: opendirSyncMock +})) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + opendir: opendirMock +})) + +import { + findNestedSpeechModelDirectory, + findSpeechModelBpeVocabFile, + SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES, + SpeechModelDirectoryCapacityError +} from './speech-model-directory-scanner' +import { buildHotwordsConfig } from './stt-worker-model-config' + +type FakeEntry = { name: string; directory?: boolean } + +function fakeAsyncDirectory(entries: FakeEntry[], onClose?: () => void): object { + return { + async *[Symbol.asyncIterator]() { + try { + for (const entry of entries) { + yield { + name: entry.name, + isDirectory: () => entry.directory === true + } + } + } finally { + onClose?.() + } + } + } +} + +function useSyncEntries(entries: FakeEntry[]): ReturnType { + let index = 0 + const closeSync = vi.fn() + opendirSyncMock.mockReturnValue({ + closeSync, + readSync: vi.fn(() => { + const entry = entries[index] + index += 1 + return entry ? { name: entry.name } : null + }) + }) + return closeSync +} + +describe('speech model directory scanner', () => { + beforeEach(() => { + opendirMock.mockReset() + opendirSyncMock.mockReset() + }) + + it('preserves the first matching nested directory and its entry order', async () => { + opendirMock.mockImplementation(async (path: string) => { + if (path === '/models') { + return fakeAsyncDirectory([ + { name: 'unrelated', directory: true }, + { name: 'archive', directory: true } + ]) + } + if (path === '/models/unrelated') { + return fakeAsyncDirectory([{ name: 'README.md' }]) + } + return fakeAsyncDirectory([{ name: 'encoder.onnx' }, { name: 'tokens.txt' }]) + }) + + await expect(findNestedSpeechModelDirectory('/models', ['encoder.onnx'])).resolves.toEqual({ + directoryPath: '/models/archive', + entryNames: ['encoder.onnx', 'tokens.txt'] + }) + }) + + it('accepts the exact archive entry limit', async () => { + opendirMock.mockImplementation(async (path: string) => + path === '/models' + ? fakeAsyncDirectory([{ name: 'archive', directory: true }]) + : fakeAsyncDirectory([{ name: 'encoder.onnx' }, { name: 'tokens.txt' }]) + ) + + await expect( + findNestedSpeechModelDirectory('/models', ['encoder.onnx'], { + maxEntries: 3 + }) + ).resolves.toMatchObject({ entryNames: ['encoder.onnx', 'tokens.txt'] }) + }) + + it('closes both streams and rejects the first entry over the archive limit', async () => { + const closed: string[] = [] + opendirMock.mockImplementation(async (path: string) => + path === '/models' + ? fakeAsyncDirectory([{ name: 'archive', directory: true }], () => closed.push('root')) + : fakeAsyncDirectory([{ name: 'one' }, { name: 'two' }, { name: 'three' }], () => + closed.push('nested') + ) + ) + + await expect( + findNestedSpeechModelDirectory('/models', ['missing'], { + maxEntries: 3 + }) + ).rejects.toBeInstanceOf(SpeechModelDirectoryCapacityError) + expect(closed).toEqual(['nested', 'root']) + }) + + it('rejects a nested listing above its retained-name budget', async () => { + opendirMock.mockImplementation(async (path: string) => + path === '/models' + ? fakeAsyncDirectory([{ name: 'archive', directory: true }]) + : fakeAsyncDirectory([{ name: 'x' }]) + ) + + await expect( + findNestedSpeechModelDirectory('/models', ['x'], { + maxRetainedNameBytes: 65 + }) + ).rejects.toThrow('retained name bytes') + }) + + it('finds a vocab at the exact production scan boundary', () => { + const entries = Array.from({ length: SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES - 1 }, (_, index) => ({ + name: `file-${index}` + })) + entries.push({ name: 'tokens.vocab' }) + const closeSync = useSyncEntries(entries) + + expect( + buildHotwordsConfig({ + modelDir: '/models', + modelType: 'transducer', + hotwordsFilePath: '/hotwords.txt', + modelingUnit: 'bpe' + }) + ).toMatchObject({ + decodingMethod: 'modified_beam_search', + bpeVocab: '/models/tokens.vocab' + }) + expect(closeSync).toHaveBeenCalledOnce() + }) + + it('ignores a vocab beyond the production scan boundary and closes the stream', () => { + const entries = Array.from({ length: SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES }, (_, index) => ({ + name: `file-${index}` + })) + entries.push({ name: 'too-late.vocab' }) + const closeSync = useSyncEntries(entries) + + expect( + buildHotwordsConfig({ + modelDir: '/models', + modelType: 'transducer', + hotwordsFilePath: '/hotwords.txt', + modelingUnit: 'bpe' + }) + ).toEqual({ decodingMethod: 'greedy_search' }) + expect(findSpeechModelBpeVocabFile('/models', 0)).toBeUndefined() + expect(closeSync).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/speech/speech-model-directory-scanner.ts b/src/main/speech/speech-model-directory-scanner.ts new file mode 100644 index 00000000000..5ca7ef01b14 --- /dev/null +++ b/src/main/speech/speech-model-directory-scanner.ts @@ -0,0 +1,130 @@ +import { opendirSync } from 'node:fs' +import { opendir } from 'node:fs/promises' +import { join } from 'node:path' + +export const SPEECH_MODEL_ARCHIVE_SCAN_MAX_ENTRIES = 32_768 +export const SPEECH_MODEL_ARCHIVE_MAX_RETAINED_NAME_BYTES = 4 * 1024 * 1024 +export const SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES = 4_096 + +export type SpeechModelArchiveScanLimits = { + maxEntries: number + maxRetainedNameBytes: number +} + +export type NestedSpeechModelDirectory = { + directoryPath: string + entryNames: string[] +} + +export class SpeechModelDirectoryCapacityError extends Error { + constructor(resource: string, limit: number) { + super(`Speech model directory exceeded its ${resource} limit (${limit})`) + this.name = 'SpeechModelDirectoryCapacityError' + } +} + +function resolveLimit(requested: number | undefined, maximum: number, name: string): number { + if (requested === undefined) { + return maximum + } + if (!Number.isSafeInteger(requested) || requested < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + return Math.min(requested, maximum) +} + +function estimateRetainedNameBytes(name: string): number { + return name.length * 2 + 64 +} + +export async function findNestedSpeechModelDirectory( + modelDir: string, + expectedFiles: readonly string[], + requestedLimits: Partial = {} +): Promise { + const maxEntries = resolveLimit( + requestedLimits.maxEntries, + SPEECH_MODEL_ARCHIVE_SCAN_MAX_ENTRIES, + 'maxEntries' + ) + const maxRetainedNameBytes = resolveLimit( + requestedLimits.maxRetainedNameBytes, + SPEECH_MODEL_ARCHIVE_MAX_RETAINED_NAME_BYTES, + 'maxRetainedNameBytes' + ) + const expectedFileNames = new Set(expectedFiles) + let scannedEntries = 0 + + const rootDirectory = await opendir(modelDir, { bufferSize: 32 }) + for await (const entry of rootDirectory) { + scannedEntries += 1 + if (scannedEntries > maxEntries) { + throw new SpeechModelDirectoryCapacityError('entry count', maxEntries) + } + if (!entry.isDirectory()) { + continue + } + + const nestedDirectoryPath = join(modelDir, entry.name) + const entryNames: string[] = [] + let retainedNameBytes = 0 + const nestedDirectory = await opendir(nestedDirectoryPath, { bufferSize: 32 }) + for await (const nestedEntry of nestedDirectory) { + scannedEntries += 1 + if (scannedEntries > maxEntries) { + throw new SpeechModelDirectoryCapacityError('entry count', maxEntries) + } + retainedNameBytes += estimateRetainedNameBytes(nestedEntry.name) + if (retainedNameBytes > maxRetainedNameBytes) { + throw new SpeechModelDirectoryCapacityError('retained name bytes', maxRetainedNameBytes) + } + entryNames.push(nestedEntry.name) + } + if (entryNames.some((name) => expectedFileNames.has(name))) { + return { directoryPath: nestedDirectoryPath, entryNames } + } + } + return null +} + +function closeSpeechModelDirectory(directory: ReturnType): void { + try { + directory.closeSync() + } catch { + // The OS may already have closed a fully consumed directory stream. + } +} + +export function findSpeechModelBpeVocabFile( + modelDir: string, + requestedMaxEntries = SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES +): string | undefined { + const maxEntries = resolveLimit( + requestedMaxEntries, + SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES, + 'maxEntries' + ) + let directory: ReturnType + try { + directory = opendirSync(modelDir, { bufferSize: 32 }) + } catch { + return undefined + } + + let scannedEntries = 0 + try { + while (scannedEntries < maxEntries) { + const entry = directory.readSync() + if (entry === null) { + return undefined + } + scannedEntries += 1 + if (entry.name.endsWith('.vocab')) { + return join(modelDir, entry.name) + } + } + return undefined + } finally { + closeSpeechModelDirectory(directory) + } +} diff --git a/src/main/speech/speech-model-extraction-stderr.test.ts b/src/main/speech/speech-model-extraction-stderr.test.ts new file mode 100644 index 00000000000..9a851ccc517 --- /dev/null +++ b/src/main/speech/speech-model-extraction-stderr.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES, + SpeechModelExtractionStderr +} from './speech-model-extraction-stderr' + +describe('speech model extraction stderr', () => { + it('preserves ordinary error evidence exactly', () => { + const stderr = new SpeechModelExtractionStderr() + stderr.append(Buffer.from('tar: archive is corrupt\n')) + stderr.append(Buffer.from('tar: exiting with failure')) + + expect(stderr.errorEvidence()).toBe('tar: archive is corrupt\ntar: exiting with failure') + expect(stderr.retainedByteLength()).toBe(49) + expect(stderr.wasTruncated()).toBe(false) + }) + + it('retains the exact byte limit without truncation', () => { + const stderr = new SpeechModelExtractionStderr() + stderr.append(Buffer.alloc(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES, 0x61)) + + expect(stderr.retainedByteLength()).toBe(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES) + expect(stderr.wasTruncated()).toBe(false) + expect(stderr.errorEvidence()).toBe('a'.repeat(500)) + }) + + it('caps one byte over the limit while preserving prefix and tail evidence', () => { + const stderr = new SpeechModelExtractionStderr() + stderr.append(Buffer.alloc(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES, 0x61)) + stderr.append(Buffer.from('Z')) + + expect(stderr.retainedByteLength()).toBe(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES) + expect(stderr.wasTruncated()).toBe(true) + expect(stderr.errorEvidence()).toMatch( + /^a{500}\n\[\.\.\. 1 stderr bytes omitted \.\.\.\]\na{499}Z$/ + ) + }) + + it('bounds a single oversized chunk without converting it to an oversized string', () => { + const stderr = new SpeechModelExtractionStderr() + const chunk = Buffer.concat([ + Buffer.from('PREFIX'), + Buffer.alloc(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES * 2, 0x78), + Buffer.from('TAIL') + ]) + + stderr.append(chunk) + + expect(stderr.retainedByteLength()).toBe(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES) + expect(stderr.errorEvidence()).toContain('PREFIX') + expect(stderr.errorEvidence()).toMatch(/TAIL$/) + }) +}) diff --git a/src/main/speech/speech-model-extraction-stderr.ts b/src/main/speech/speech-model-extraction-stderr.ts new file mode 100644 index 00000000000..146d97dda37 --- /dev/null +++ b/src/main/speech/speech-model-extraction-stderr.ts @@ -0,0 +1,74 @@ +export const SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES = 64 * 1024 + +const STDERR_PREFIX_BYTES = 4 * 1024 +const STDERR_TAIL_BYTES = SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES - STDERR_PREFIX_BYTES +const STDERR_EVIDENCE_CHARS_PER_END = 500 + +export class SpeechModelExtractionStderr { + private readonly prefix = Buffer.alloc(STDERR_PREFIX_BYTES) + private readonly tail = Buffer.alloc(STDERR_TAIL_BYTES) + private prefixLength = 0 + private tailLength = 0 + private tailWriteOffset = 0 + private observedBytes = 0 + + append(chunk: Buffer): void { + if (chunk.length === 0) { + return + } + if (this.prefixLength < this.prefix.length) { + const prefixBytes = Math.min(chunk.length, this.prefix.length - this.prefixLength) + chunk.copy(this.prefix, this.prefixLength, 0, prefixBytes) + this.prefixLength += prefixBytes + } + this.appendTail(chunk) + this.observedBytes = Math.min(Number.MAX_SAFE_INTEGER, this.observedBytes + chunk.length) + } + + retainedByteLength(): number { + return Math.min(this.observedBytes, SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES) + } + + wasTruncated(): boolean { + return this.observedBytes > SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES + } + + errorEvidence(): string { + const prefix = this.prefix + .subarray(0, this.prefixLength) + .toString('utf8') + .slice(0, STDERR_EVIDENCE_CHARS_PER_END) + if (!this.wasTruncated()) { + return prefix + } + const omittedBytes = this.observedBytes - this.retainedByteLength() + const tail = this.orderedTail().toString('utf8').slice(-STDERR_EVIDENCE_CHARS_PER_END) + return `${prefix}\n[... ${omittedBytes} stderr bytes omitted ...]\n${tail}` + } + + private appendTail(chunk: Buffer): void { + if (chunk.length >= this.tail.length) { + chunk.copy(this.tail, 0, chunk.length - this.tail.length) + this.tailLength = this.tail.length + this.tailWriteOffset = 0 + return + } + const firstBytes = Math.min(chunk.length, this.tail.length - this.tailWriteOffset) + chunk.copy(this.tail, this.tailWriteOffset, 0, firstBytes) + if (firstBytes < chunk.length) { + chunk.copy(this.tail, 0, firstBytes) + } + this.tailWriteOffset = (this.tailWriteOffset + chunk.length) % this.tail.length + this.tailLength = Math.min(this.tail.length, this.tailLength + chunk.length) + } + + private orderedTail(): Buffer { + if (this.tailLength < this.tail.length || this.tailWriteOffset === 0) { + return this.tail.subarray(0, this.tailLength) + } + return Buffer.concat([ + this.tail.subarray(this.tailWriteOffset), + this.tail.subarray(0, this.tailWriteOffset) + ]) + } +} diff --git a/src/main/speech/stt-worker-model-config.ts b/src/main/speech/stt-worker-model-config.ts index de4687d9f46..c9f269c8908 100644 --- a/src/main/speech/stt-worker-model-config.ts +++ b/src/main/speech/stt-worker-model-config.ts @@ -1,5 +1,5 @@ -import { readdirSync } from 'node:fs' import { join } from 'node:path' +import { findSpeechModelBpeVocabFile } from './speech-model-directory-scanner' // Why: different models name their ONNX files differently (e.g. // encoder.int8.onnx vs tiny-encoder.onnx vs encoder-epoch-99-avg-1.onnx). @@ -29,16 +29,6 @@ export function resolveTokens(files: string[], modelDir: string): string { // Why: BPE models need a vocab file for hotwords token matching. The file // ships in the model archive but isn't listed in the manifest. We discover // it at runtime to avoid breaking existing downloads. -function discoverBpeVocab(modelDir: string): string | undefined { - try { - const entries = readdirSync(modelDir) - const vocabFile = entries.find((f) => f.endsWith('.vocab')) - return vocabFile ? join(modelDir, vocabFile) : undefined - } catch { - return undefined - } -} - export type HotwordsConfig = { decodingMethod: string hotwordsFile?: string @@ -59,7 +49,7 @@ export function buildHotwordsConfig(opts: { const unit = opts.modelingUnit if (unit?.includes('bpe')) { - const bpeVocab = discoverBpeVocab(opts.modelDir) + const bpeVocab = findSpeechModelBpeVocabFile(opts.modelDir) if (!bpeVocab) { return { decodingMethod: 'greedy_search' } } diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts index 459eaef88df..3e2ada59323 100644 --- a/src/main/ssh/relay-protocol.test.ts +++ b/src/main/ssh/relay-protocol.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { HEADER_LENGTH, + MAX_BUFFERED_FRAME_CHUNKS, MessageType, encodeFrame, encodeJsonRpcFrame, @@ -12,6 +13,7 @@ import { type JsonRpcRequest, type DecodedFrame } from './relay-protocol' +import { RELAY_JSON_MAX_STRUCTURAL_TOKENS } from '../../shared/relay-json-admission' describe('git response stream marker', () => { it('accepts only complete non-negative integer metadata', () => { @@ -26,6 +28,15 @@ describe('git response stream marker', () => { __orcaGitResponseStream: { streamId: -1, totalBytes: 1024, chunkCount: 2 } }) ).toBe(false) + expect( + isGitResponseStreamMarker({ + __orcaGitResponseStream: { + streamId: 1, + totalBytes: Number.MAX_SAFE_INTEGER + 1, + chunkCount: 2 + } + }) + ).toBe(false) }) }) @@ -79,11 +90,37 @@ describe('frame encoding', () => { method: 'x', params: { data: 'a'.repeat(17 * 1024 * 1024) } } - expect(() => encodeJsonRpcFrame(bigPayload, 1, 0)).toThrow('Message too large') + const fromSpy = vi.spyOn(Buffer, 'from') + try { + expect(() => encodeJsonRpcFrame(bigPayload, 1, 0)).toThrow('Message too large') + expect(fromSpy).not.toHaveBeenCalled() + } finally { + fromSpy.mockRestore() + } }) }) describe('FrameDecoder', () => { + it('bounds retained chunk wrappers for a byte-fragmented frame', () => { + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((frame) => frames.push(frame)) + const payloadLength = MAX_BUFFERED_FRAME_CHUNKS + 512 + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = MessageType.Regular + header.writeUInt32BE(payloadLength, 9) + decoder.feed(header) + + const byte = Buffer.from('x') + const state = decoder as unknown as { chunks: Buffer[] } + for (let index = 0; index < payloadLength; index += 1) { + decoder.feed(byte) + expect(state.chunks.length).toBeLessThanOrEqual(MAX_BUFFERED_FRAME_CHUNKS) + } + + expect(frames).toHaveLength(1) + expect(frames[0].payload.equals(Buffer.alloc(payloadLength, 0x78))).toBe(true) + }) + it('decodes a complete frame', () => { const frames: DecodedFrame[] = [] const decoder = new FrameDecoder((f) => frames.push(f)) @@ -163,6 +200,33 @@ describe('FrameDecoder', () => { expect(errors[0].message).toContain('discarded') }) + it('rejects an oversized header immediately without retaining its payload', () => { + const errors: Error[] = [] + const decoder = new FrameDecoder( + () => {}, + (err) => errors.push(err) + ) + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = MessageType.Regular + header.writeUInt32BE(0xffffffff, 9) + + decoder.feed(header) + const state = decoder as unknown as { + bufferedLength: number + oversizedPayloadBytesRemaining: number + } + expect(errors).toHaveLength(1) + expect(state.bufferedLength).toBe(0) + expect(state.oversizedPayloadBytesRemaining).toBe(0xffffffff) + + const payloadChunk = Buffer.alloc(64 * 1024) + for (let index = 0; index < 128; index += 1) { + decoder.feed(payloadChunk) + expect(state.bufferedLength).toBe(0) + } + expect(errors).toHaveLength(1) + }) + it('reset clears internal buffer', () => { const frames: DecodedFrame[] = [] const decoder = new FrameDecoder((f) => frames.push(f)) @@ -224,26 +288,31 @@ describe('FrameDecoder', () => { expect(frames[0].payload.toString()).toBe('after') }) - it('never rebuilds the buffered stream per feed while assembling a large frame', () => { - // Regression: feed() used Buffer.concat([buffered, chunk]) per data event, - // re-copying the whole backlog for every TCP chunk — O(n²) memcpy on the - // Electron main thread while fs.streamChunk frames arrive (SSH typing lag). + it('retains ordinary transport chunks without copying them while a frame is incomplete', () => { const frames: DecodedFrame[] = [] const decoder = new FrameDecoder((f) => frames.push(f)) - const frame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(512 * 1024, 0x61)) + const payloadLength = 1024 * 1024 + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = MessageType.Regular + header.writeUInt32BE(1, 1) + header.writeUInt32BE(payloadLength, 9) + decoder.feed(header) + const ordinaryChunks = Array.from({ length: 16 }, () => Buffer.alloc(32 * 1024, 0x61)) - const concatSpy = vi.spyOn(Buffer, 'concat') - try { - for (let i = 0; i < frame.length; i += 32 * 1024) { - decoder.feed(frame.subarray(i, i + 32 * 1024)) - } - } finally { - concatSpy.mockRestore() + for (const chunk of ordinaryChunks) { + decoder.feed(chunk) } + const state = decoder as unknown as { chunks: Buffer[] } + expect(state.chunks).toHaveLength(ordinaryChunks.length + 1) + for (const [index, chunk] of ordinaryChunks.entries()) { + expect(state.chunks[index + 1]).toBe(chunk) + } + + decoder.feed(Buffer.alloc(payloadLength / 2, 0x61)) + expect(frames).toHaveLength(1) - expect(frames[0].payload.length).toBe(512 * 1024) - expect(concatSpy).not.toHaveBeenCalled() + expect(frames[0].payload.length).toBe(payloadLength) }) }) @@ -270,6 +339,21 @@ describe('parseJsonRpcMessage', () => { const payload = Buffer.from('not json') expect(() => parseJsonRpcMessage(payload)).toThrow() }) + + it('rejects structurally amplified JSON before parsing', () => { + const payload = Buffer.from( + `{"jsonrpc":"2.0","id":1,"method":"x","params":{"values":[${'0,'.repeat( + RELAY_JSON_MAX_STRUCTURAL_TOKENS + )}0]}}` + ) + const parseSpy = vi.spyOn(JSON, 'parse') + try { + expect(() => parseJsonRpcMessage(payload)).toThrow(/JSON structure exceeds/) + expect(parseSpy).not.toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } + }) }) describe('parseUnameToRelayPlatform', () => { diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index c05b6c33bfe..e317d530ffc 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -3,6 +3,11 @@ // See design-ssh-support.md § JSON-RPC Protocol Specification. import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' +import { + JsonStringifyByteLimitError, + stringifyJsonWithinByteLimit +} from '../../shared/memory-safety/node-bounded-json-stringify' +import { parseRelayJsonText } from '../../shared/relay-json-admission' export const RELAY_VERSION = '0.1.0' export const RELAY_SENTINEL = `ORCA-RELAY v${RELAY_VERSION} READY\n` @@ -13,6 +18,8 @@ export const RELAY_REMOTE_DIR = '.orca-remote' export const HEADER_LENGTH = 13 export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024 // 16 MB +export const MAX_BUFFERED_FRAME_CHUNKS = 4_096 +const MAX_COALESCED_TINY_FRAME_CHUNK_BYTES = 8 * 1024 /** Message type byte. */ export const MessageType = { @@ -36,7 +43,8 @@ export const RelayErrorCode = { PtyAllocationFailed: -33004, DiskFull: -33005, TooManyStreams: -33006, - StreamProtocolError: -33007 + StreamProtocolError: -33007, + MarkdownDocumentListingCapacity: -33008 } as const export const JsonRpcErrorCode = { @@ -68,6 +76,11 @@ export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024 * so it stays cross-version safe. */ export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024 +/** Bounds retained serialized Git results while preserving responses far above + * the per-file render limit and git.exec's output ceiling. */ +export const MAX_GIT_RESPONSE_STREAM_BYTES = 64 * 1024 * 1024 +export const MAX_GIT_RESPONSE_STREAM_CHUNKS = 4_096 + /** Sentinel the relay returns as the RPC result when the real payload streams * as git.responseChunk frames. Absent from old relays, so a new client falls * back to the plain result they return. */ @@ -85,11 +98,11 @@ export function isGitResponseStreamMarker(value: unknown): value is GitResponseS } const fields = marker as Record return ( - Number.isInteger(fields.streamId) && + Number.isSafeInteger(fields.streamId) && (fields.streamId as number) > 0 && - Number.isInteger(fields.totalBytes) && + Number.isSafeInteger(fields.totalBytes) && (fields.totalBytes as number) >= 0 && - Number.isInteger(fields.chunkCount) && + Number.isSafeInteger(fields.chunkCount) && (fields.chunkCount as number) >= 0 ) } @@ -144,10 +157,16 @@ export function encodeFrame( } export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer { - const payload = Buffer.from(JSON.stringify(msg), 'utf-8') - if (payload.length > MAX_MESSAGE_SIZE) { - throw new Error(`Message too large: ${payload.length} bytes (max ${MAX_MESSAGE_SIZE})`) + let serialized: string + try { + serialized = stringifyJsonWithinByteLimit(msg, MAX_MESSAGE_SIZE).serialized + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + throw new Error(`Message too large: more than ${MAX_MESSAGE_SIZE} bytes`) + } + throw error } + const payload = Buffer.from(serialized, 'utf-8') return encodeFrame(MessageType.Regular, id, ack, payload) } @@ -173,6 +192,7 @@ export class FrameDecoder { // list assembles each frame exactly once instead. private chunks: Buffer[] = [] private bufferedLength = 0 + private oversizedPayloadBytesRemaining = 0 private onFrame: (frame: DecodedFrame) => void private onError: ((err: Error) => void) | null @@ -182,12 +202,18 @@ export class FrameDecoder { } feed(chunk: Buffer | Uint8Array): void { - const buf = Buffer.isBuffer(chunk) + let buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + if (this.oversizedPayloadBytesRemaining > 0 && buf.length > 0) { + const discarded = Math.min(this.oversizedPayloadBytesRemaining, buf.length) + this.oversizedPayloadBytesRemaining -= discarded + buf = buf.subarray(discarded) + } if (buf.length > 0) { this.chunks.push(buf) this.bufferedLength += buf.length + this.coalesceBufferedChunks() } while (this.bufferedLength >= HEADER_LENGTH) { @@ -195,25 +221,26 @@ export class FrameDecoder { const length = header.readUInt32BE(9) const totalLength = HEADER_LENGTH + length - if (this.bufferedLength < totalLength) { - // Not fully received yet (also holds oversized frames until they can - // be skipped whole, keeping the decoder synchronized). - break - } - - // Why: throwing here would leave the buffer in a partially consumed - // state — subsequent feed() calls would try to parse leftover payload - // bytes as a new header, corrupting every future frame. Instead we - // skip the entire oversized frame so the decoder stays synchronized. if (length > MAX_MESSAGE_SIZE) { - this.discardBytes(totalLength) + // Why: retain only the header, then stream-discard the advertised + // payload so a 13-byte malicious header cannot pin up to 4 GiB. + this.discardBytes(HEADER_LENGTH) + this.oversizedPayloadBytesRemaining = length + this.discardBufferedOversizedPayload() const err = new Error(`Frame payload too large: ${length} bytes — discarded`) if (this.onError) { this.onError(err) } + if (this.oversizedPayloadBytesRemaining > 0) { + break + } continue } + if (this.bufferedLength < totalLength) { + break + } + const framed = this.takeBytes(totalLength) const frame: DecodedFrame = { type: framed[0], @@ -228,6 +255,7 @@ export class FrameDecoder { reset(): void { this.chunks = [] this.bufferedLength = 0 + this.oversizedPayloadBytesRemaining = 0 } /** View of the first `count` buffered bytes without consuming them. */ @@ -292,14 +320,40 @@ export class FrameDecoder { } this.bufferedLength -= count } + + private discardBufferedOversizedPayload(): void { + const discarded = Math.min(this.oversizedPayloadBytesRemaining, this.bufferedLength) + if (discarded === 0) { + return + } + this.discardBytes(discarded) + this.oversizedPayloadBytesRemaining -= discarded + } + + private coalesceBufferedChunks(): void { + // Why: merge adversarial tiny fragments without copying ordinary transport chunks. + while (this.chunks.length >= 2) { + const right = this.chunks.at(-1)! + const left = this.chunks.at(-2)! + if ( + left.length > right.length || + left.length + right.length > MAX_COALESCED_TINY_FRAME_CHUNK_BYTES + ) { + break + } + this.chunks.splice(-2, 2, Buffer.concat([left, right], left.length + right.length)) + } + if (this.chunks.length > MAX_BUFFERED_FRAME_CHUNKS) { + this.chunks = [Buffer.concat(this.chunks, this.bufferedLength)] + } + } } /** * Parse a JSON-RPC message from a frame payload. */ export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { - const text = payload.toString('utf-8') - const msg = JSON.parse(text) as JsonRpcMessage + const msg = parseRelayJsonText(payload.toString('utf-8')) if (msg.jsonrpc !== '2.0') { throw new Error(`Invalid JSON-RPC version: ${(msg as Record).jsonrpc}`) } diff --git a/src/main/ssh/sftp-directory-removal.ts b/src/main/ssh/sftp-directory-removal.ts new file mode 100644 index 00000000000..546915e42bd --- /dev/null +++ b/src/main/ssh/sftp-directory-removal.ts @@ -0,0 +1,116 @@ +import type { SFTPWrapper } from 'ssh2' +import { SshDirectoryTransferBudget } from './ssh-directory-transfer-budget' + +type SftpDirectoryEntry = { + filename: string + attrs?: { + isDirectory?: () => boolean + } +} + +export async function removeDirectorySftp(sftp: SFTPWrapper, remoteDir: string): Promise { + const budget = new SshDirectoryTransferBudget() + budget.recordPath(remoteDir, 0, { countEntry: false }) + await removeDirectorySftpWithinBudget(sftp, remoteDir, budget, 0) +} + +async function removeDirectorySftpWithinBudget( + sftp: SFTPWrapper, + remoteDir: string, + budget: SshDirectoryTransferBudget, + depth: number +): Promise { + const handle = await opendirSftp(sftp, remoteDir) + const normalizedRemoteDir = remoteDir.replace(/\/+$/, '') + let completed = false + try { + for (;;) { + const entries = await readdirSftp(sftp, handle) + if (!entries) { + break + } + for (const entry of entries) { + if (entry.filename === '.' || entry.filename === '..') { + continue + } + const childPath = `${normalizedRemoteDir}/${entry.filename}` + budget.recordPath(childPath, depth + 1) + await (entry.attrs?.isDirectory?.() + ? removeDirectorySftpWithinBudget(sftp, childPath, budget, depth + 1) + : unlinkSftp(sftp, childPath)) + } + } + completed = true + } finally { + await closeSftpHandle(sftp, handle).catch((error: unknown) => { + if (completed) { + throw error + } + }) + } + await rmdirSftp(sftp, remoteDir) +} + +function opendirSftp(sftp: SFTPWrapper, remoteDir: string): Promise { + return new Promise((resolve, reject) => { + sftp.opendir(remoteDir, (err, handle) => { + if (err) { + reject(err) + return + } + resolve(handle) + }) + }) +} + +function readdirSftp(sftp: SFTPWrapper, handle: Buffer): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(handle, (err, entries) => { + if ((err as { code?: number } | undefined)?.code === 1) { + resolve(null) + return + } + if (err) { + reject(err) + return + } + resolve((entries ?? []) as SftpDirectoryEntry[]) + }) + }) +} + +function closeSftpHandle(sftp: SFTPWrapper, handle: Buffer): Promise { + return new Promise((resolve, reject) => { + sftp.close(handle, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + +function unlinkSftp(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.unlink(remotePath, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + +function rmdirSftp(sftp: SFTPWrapper, remoteDir: string): Promise { + return new Promise((resolve, reject) => { + sftp.rmdir(remoteDir, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} diff --git a/src/main/ssh/sftp-namespace-resolution.test.ts b/src/main/ssh/sftp-namespace-resolution.test.ts new file mode 100644 index 00000000000..f404d27979d --- /dev/null +++ b/src/main/ssh/sftp-namespace-resolution.test.ts @@ -0,0 +1,361 @@ +// Why: picking the wrong SFTP path silently installs the relay somewhere the shell +// will never launch it, so every discovery outcome needs a pinned decision. + +import type { SFTPWrapper } from 'ssh2' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + resolveSftpTransferPath, + resolveSftpTransferPathIfMapped, + type SftpNamespacePathMapping +} from './sftp-namespace-resolution' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const SHELL_HOME = '/var/services/homes/alice' +const RELAY_DIR = '.orca-remote/relay-0.1.0+hash' +const MARKER = '.install-lock/.sftp-namespace-deadbeef' + +const mapping: SftpNamespacePathMapping = { + homeRelativePath: RELAY_DIR, + shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/${MARKER}`, + homeRelativeProbePath: `${RELAY_DIR}/${MARKER}` +} + +type LstatOutcome = 'present' | { code: number } | { message: string } + +function statusError(code: number): Error { + return Object.assign(new Error(`SFTP status ${code}`), { code }) +} + +// A marker probe must care only about "the call succeeded", never about the reported type or size. +const MARKER_STATS = { + isDirectory: () => false, + isSymbolicLink: () => true, + mode: 0o120_777, + size: 0 +} + +function makeSftp(options: { + startPath?: string | Error | unknown + lstat?: (path: string) => LstatOutcome +}): { + sftp: SFTPWrapper + realpathCalls: string[] + lstatCalls: string[] +} { + const realpathCalls: string[] = [] + const lstatCalls: string[] = [] + const sftp = { + realpath: vi.fn((path: string, cb: (err: Error | null, resolved?: unknown) => void) => { + realpathCalls.push(path) + if (options.startPath instanceof Error) { + cb(options.startPath) + return + } + cb(null, options.startPath) + }), + lstat: vi.fn((path: string, cb: (err: Error | null, stats?: unknown) => void) => { + lstatCalls.push(path) + const outcome = options.lstat?.(path) ?? { code: 2 } + if (outcome === 'present') { + cb(null, MARKER_STATS) + return + } + cb('code' in outcome ? statusError(outcome.code) : new Error(outcome.message)) + }) + } + return { sftp: sftp as unknown as SFTPWrapper, realpathCalls, lstatCalls } +} + +// The marker lives under the SFTP start directory but not under the shell path. +function divergentLstat(startPath: string) { + return (path: string): LstatOutcome => + path === `${startPath}/${RELAY_DIR}/${MARKER}` ? 'present' : { code: 2 } +} + +describe('resolveSftpTransferPath', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('keeps the shell path and skips probing when both namespaces agree', async () => { + const { sftp, lstatCalls } = makeSftp({ startPath: SHELL_HOME }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(lstatCalls).toEqual([]) + }) + + it('redirects to the SFTP namespace when only that side carries our marker', async () => { + const { sftp, lstatCalls } = makeSftp({ + startPath: '/homes/alice', + lstat: divergentLstat('/homes/alice') + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`/homes/alice/${RELAY_DIR}`) + expect(lstatCalls).toEqual([ + `${SHELL_HOME}/${RELAY_DIR}/${MARKER}`, + `/homes/alice/${RELAY_DIR}/${MARKER}` + ]) + }) + + it('handles a start directory that is not a home directory at all', async () => { + const { sftp } = makeSftp({ + startPath: '/volume1/shared', + lstat: divergentLstat('/volume1/shared') + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`/volume1/shared/${RELAY_DIR}`) + }) + + it('normalizes a trailing slash on the reported start directory', async () => { + const { sftp } = makeSftp({ + startPath: '/homes/alice/', + lstat: divergentLstat('/homes/alice') + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`/homes/alice/${RELAY_DIR}`) + }) + + it('resolves a nested file path, not just the relay directory', async () => { + const fileMapping: SftpNamespacePathMapping = { + ...mapping, + homeRelativePath: `${RELAY_DIR}/package.json` + } + const { sftp } = makeSftp({ + startPath: '/homes/alice', + lstat: divergentLstat('/homes/alice') + }) + + const resolved = await resolveSftpTransferPath( + sftp, + `${SHELL_HOME}/${RELAY_DIR}/package.json`, + fileMapping + ) + + expect(resolved).toBe(`/homes/alice/${RELAY_DIR}/package.json`) + }) + + it('refuses an unrelated same-version directory that lacks our marker', async () => { + const { sftp, lstatCalls } = makeSftp({ startPath: '/homes/alice' }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(lstatCalls).toHaveLength(2) + }) + + it('keeps the shell path when the marker is already visible there', async () => { + const { sftp, lstatCalls } = makeSftp({ + startPath: '/homes/alice', + lstat: (path) => (path === mapping.shellProbePath ? 'present' : { code: 2 }) + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(lstatCalls).toEqual([mapping.shellProbePath]) + }) + + // Why: only SSH_FX_NO_SUCH_FILE proves absence; anything else must not license a redirect. + it.each([ + ['generic failure', { code: 4 } as LstatOutcome], + ['permission denied', { code: 3 } as LstatOutcome], + ['a code-less transport error', { message: 'socket hang up' } as LstatOutcome] + ])('never probes the candidate after %s on the shell marker', async (_label, outcome) => { + const { sftp, lstatCalls } = makeSftp({ + startPath: '/homes/alice', + lstat: (path) => (path === mapping.shellProbePath ? outcome : 'present') + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(lstatCalls).toEqual([mapping.shellProbePath]) + }) + + it('keeps the shell path when the candidate probe is inconclusive', async () => { + const { sftp } = makeSftp({ + startPath: '/homes/alice', + lstat: (path) => (path === mapping.shellProbePath ? { code: 2 } : { code: 4 }) + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('retaining shell path')) + }) + + it.each([ + ['REALPATH fails', new Error('permission denied')], + ['REALPATH returns a relative path', 'homes/alice'], + ['REALPATH returns a non-string', 42], + ['REALPATH smuggles a line break', '/homes/alice\nrm -rf /'], + ['REALPATH contains an empty component', '/homes//alice'], + ['REALPATH contains a dot component', '/homes/./alice'], + ['REALPATH contains a traversal component', '/homes/archive/../alice'] + ])('keeps the shell path and skips LSTAT when %s', async (_label, startPath) => { + const { sftp, lstatCalls } = makeSftp({ startPath }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(lstatCalls).toEqual([]) + }) + + it.each([ + ['a relative shell path', { shell: `${SHELL_HOME}/${RELAY_DIR}`.slice(1) }, 'SFTP namespace'], + ['an absolute home-relative path', { homeRelativePath: `/${RELAY_DIR}` }, 'SFTP namespace'], + ['a traversal segment', { homeRelativePath: `${RELAY_DIR}/../escape` }, 'Unsafe remote path'], + [ + 'a traversal segment in the absolute shell path', + { shell: `${SHELL_HOME}/archive/../${RELAY_DIR}` }, + 'Unsafe remote path' + ], + [ + 'an empty component in the absolute shell path', + { shell: `${SHELL_HOME}//${RELAY_DIR}` }, + 'Unsafe remote path' + ], + [ + 'a dot component in the absolute marker path', + { + shellProbePath: `${SHELL_HOME}/./${RELAY_DIR}/${MARKER}`, + homeRelativeProbePath: `${RELAY_DIR}/${MARKER}` + }, + 'Unsafe remote path' + ], + ['a line break in the marker path', { shellProbePath: `${SHELL_HOME}/a\nb` }, 'SFTP namespace'], + [ + 'a mismatched transfer suffix', + { shell: `${SHELL_HOME}/${RELAY_DIR}/other` }, + 'share one home-relative suffix' + ], + [ + 'a mismatched shell namespace prefix', + { shellProbePath: `/other/home/${RELAY_DIR}/${MARKER}` }, + 'share one shell namespace prefix' + ], + [ + 'a mismatched marker basename', + { homeRelativeProbePath: `${RELAY_DIR}/.install-lock/.sftp-namespace-other` }, + 'marker paths must share one marker basename' + ], + [ + 'a marker outside the install lock', + { + shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/other-lock/.sftp-namespace-deadbeef`, + homeRelativeProbePath: `${RELAY_DIR}/other-lock/.sftp-namespace-deadbeef` + }, + 'inside the transfer relay install lock' + ], + [ + 'a marker under another relay tree', + { + shellProbePath: `${SHELL_HOME}/other/.install-lock/.sftp-namespace-deadbeef`, + homeRelativeProbePath: 'other/.install-lock/.sftp-namespace-deadbeef' + }, + 'inside the transfer relay install lock' + ] + ])('rejects %s before issuing any SFTP request', async (_label, overrides, expected) => { + const { shell, ...mappingOverrides } = overrides as Record + const { sftp, realpathCalls, lstatCalls } = makeSftp({ startPath: SHELL_HOME }) + + await expect( + resolveSftpTransferPath(sftp, shell ?? `${SHELL_HOME}/${RELAY_DIR}`, { + ...mapping, + ...mappingOverrides + }) + ).rejects.toThrow(expected) + expect(realpathCalls).toEqual([]) + expect(lstatCalls).toEqual([]) + }) + + it('redacts marker tokens from discovery diagnostics', async () => { + const token = 'a'.repeat(32) + const secretMarker = `.install-lock/.sftp-namespace-${token}` + const secretMapping: SftpNamespacePathMapping = { + homeRelativePath: RELAY_DIR, + shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/${secretMarker}`, + homeRelativeProbePath: `${RELAY_DIR}/${secretMarker}` + } + const { sftp } = makeSftp({ + startPath: '/homes/alice', + lstat: () => ({ message: `failure at ${secretMapping.shellProbePath}` }) + }) + + await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, secretMapping) + + const warnings = vi.mocked(console.warn).mock.calls.flat().join('\n') + expect(warnings).toContain('.sftp-namespace-[redacted]') + expect(warnings).not.toContain(token) + }) +}) + +describe('resolveSftpTransferPathIfMapped', () => { + it('issues no discovery requests when no mapping was supplied', async () => { + const { sftp, realpathCalls, lstatCalls } = makeSftp({ startPath: '/homes/alice' }) + + const resolved = await resolveSftpTransferPathIfMapped(sftp, `${SHELL_HOME}/${RELAY_DIR}`, { + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(realpathCalls).toEqual([]) + expect(lstatCalls).toEqual([]) + }) + + // Why: Windows SFTP reports drive paths like /C:/Users/alice, which break the POSIX prefix contract. + it('ignores a mapping on a Windows host', async () => { + const { sftp, realpathCalls } = makeSftp({ + startPath: '/homes/alice', + lstat: divergentLstat('/homes/alice') + }) + + const resolved = await resolveSftpTransferPathIfMapped(sftp, `${SHELL_HOME}/${RELAY_DIR}`, { + hostPlatform: getRemoteHostPlatform('win32-x64'), + sftpNamespace: mapping + }) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(realpathCalls).toEqual([]) + }) + + it('conservatively ignores a Windows path when platform metadata is absent', async () => { + const { sftp, realpathCalls, lstatCalls } = makeSftp({ + startPath: '/homes/alice', + lstat: divergentLstat('/homes/alice') + }) + const windowsPath = 'C:\\Users\\alice\\relay\\.version' + + const resolved = await resolveSftpTransferPathIfMapped(sftp, windowsPath, { + sftpNamespace: mapping + }) + + expect(resolved).toBe(windowsPath) + expect(realpathCalls).toEqual([]) + expect(lstatCalls).toEqual([]) + }) + + it('resolves on a POSIX host with a mapping', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + const { sftp } = makeSftp({ + startPath: '/homes/alice', + lstat: divergentLstat('/homes/alice') + }) + + const resolved = await resolveSftpTransferPathIfMapped(sftp, `${SHELL_HOME}/${RELAY_DIR}`, { + hostPlatform: getRemoteHostPlatform('linux-x64'), + sftpNamespace: mapping + }) + + expect(resolved).toBe(`/homes/alice/${RELAY_DIR}`) + }) +}) diff --git a/src/main/ssh/sftp-namespace-resolution.ts b/src/main/ssh/sftp-namespace-resolution.ts new file mode 100644 index 00000000000..1020d915127 --- /dev/null +++ b/src/main/ssh/sftp-namespace-resolution.ts @@ -0,0 +1,245 @@ +// Resolve an SFTP transfer path when the SSH shell and the SFTP subsystem expose +// different absolute namespaces for the same directory (e.g. Synology DSM's +// /var/services/homes/alice shell home vs. /homes/alice SFTP start directory). +// +// See: docs/ssh-relay-sftp-namespace.md + +import type { SFTPWrapper } from 'ssh2' +import { assertSafeRemotePathSegment, isWindowsRemoteHost } from './ssh-remote-platform' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { redactRelayInstallMarkerTokens } from './ssh-relay-install-marker' + +export type SftpNamespacePathMapping = { + homeRelativePath: string + shellProbePath: string + homeRelativeProbePath: string +} + +// SSH_FX_NO_SUCH_FILE. The only status that definitively proves a path is absent; +// permission denied, generic failure, and code-less errors are inconclusive. +const SFTP_STATUS_NO_SUCH_FILE = 2 + +type MarkerProbe = + | { kind: 'present' } + | { kind: 'absent' } + | { kind: 'inconclusive'; detail: string } + +function hasNulOrLineBreak(value: string): boolean { + return value.includes('\0') || value.includes('\r') || value.includes('\n') +} + +function assertAbsolutePosixPath(label: string, value: string): void { + if (!value.startsWith('/') || hasNulOrLineBreak(value)) { + throw new Error( + `SFTP namespace ${label} must be an absolute POSIX path: ${JSON.stringify(redactRelayInstallMarkerTokens(value))}` + ) + } + // Same segment hygiene as REALPATH start paths — empty/`.`/`..` are programming errors. + if (value === '/') { + return + } + for (const segment of value.slice(1).split('/')) { + assertSafeRemotePathSegment(segment, 'posix') + } +} + +function assertHomeRelativePosixPath(label: string, value: string): void { + if (!value || value.startsWith('/') || hasNulOrLineBreak(value)) { + throw new Error( + `SFTP namespace ${label} must be a relative POSIX path: ${JSON.stringify(redactRelayInstallMarkerTokens(value))}` + ) + } + for (const segment of value.split('/')) { + assertSafeRemotePathSegment(segment, 'posix') + } +} + +function assertMappingIdentity(shellAbsolutePath: string, mapping: SftpNamespacePathMapping): void { + if (!shellAbsolutePath.endsWith(`/${mapping.homeRelativePath}`)) { + throw new Error('SFTP namespace transfer paths must share one home-relative suffix') + } + + const probeSegments = mapping.homeRelativeProbePath.split('/') + const markerFileName = probeSegments.at(-1) + const shellMarkerFileName = mapping.shellProbePath.slice( + mapping.shellProbePath.lastIndexOf('/') + 1 + ) + if (!markerFileName || shellMarkerFileName !== markerFileName) { + throw new Error('SFTP namespace marker paths must share one marker basename') + } + const shellNamespacePrefix = shellAbsolutePath.slice(0, -mapping.homeRelativePath.length) + if (mapping.shellProbePath !== `${shellNamespacePrefix}${mapping.homeRelativeProbePath}`) { + throw new Error('SFTP namespace transfer and marker must share one shell namespace prefix') + } + + const lockSegmentIndex = probeSegments.length - 2 + const relayDir = probeSegments.slice(0, lockSegmentIndex).join('/') + if ( + lockSegmentIndex < 1 || + probeSegments[lockSegmentIndex] !== '.install-lock' || + (mapping.homeRelativePath !== relayDir && !mapping.homeRelativePath.startsWith(`${relayDir}/`)) + ) { + throw new Error('SFTP namespace marker must be inside the transfer relay install lock') + } +} + +function normalizeSftpStartPath(value: unknown): string | null { + if (typeof value !== 'string' || !value.startsWith('/') || hasNulOrLineBreak(value)) { + return null + } + if (value === '/') { + return value + } + const normalized = value.replace(/\/+$/, '') + if (!normalized.startsWith('/')) { + return null + } + const segments = normalized.slice(1).split('/') + return segments.some((segment) => !segment || segment === '.' || segment === '..') + ? null + : normalized +} + +function joinSftpStartPath(startPath: string, homeRelativePath: string): string { + return `${startPath.replace(/\/+$/, '')}/${homeRelativePath}` +} + +function realpathSftp(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.realpath(remotePath, (err, resolved) => { + if (err) { + reject(err) + return + } + resolve(resolved) + }) + }) +} + +// Why: sftpPathExists collapses "absent" and "could not tell" into one boolean; +// namespace selection must never treat an inconclusive probe as absence. +function probeMarkerPath(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve) => { + sftp.lstat(remotePath, (err) => { + if (!err) { + resolve({ kind: 'present' }) + return + } + const code = (err as { code?: unknown }).code + if (code === SFTP_STATUS_NO_SUCH_FILE) { + resolve({ kind: 'absent' }) + return + } + resolve({ + kind: 'inconclusive', + detail: + typeof code === 'number' ? `status ${code}` : redactRelayInstallMarkerTokens(err.message) + }) + }) + }) +} + +function logRetainedShellPath(operation: string, detail: string, shellAbsolutePath: string): void { + console.warn( + `[ssh-relay] SFTP namespace discovery inconclusive (${operation}: ${redactRelayInstallMarkerTokens(detail)}); retaining shell path ${redactRelayInstallMarkerTokens(shellAbsolutePath)}` + ) +} + +/** + * Pick the path this SFTP session should transfer to. + * + * Returns `shellAbsolutePath` unless the session both fails to see the install + * owner's marker there and does see it under its own start directory. Discovery + * failures degrade to the shell path rather than inventing a namespace error. + */ +export async function resolveSftpTransferPath( + sftp: SFTPWrapper, + shellAbsolutePath: string, + mapping: SftpNamespacePathMapping +): Promise { + assertAbsolutePosixPath('transfer path', shellAbsolutePath) + assertAbsolutePosixPath('marker path', mapping.shellProbePath) + assertHomeRelativePosixPath('relative transfer path', mapping.homeRelativePath) + assertHomeRelativePosixPath('relative marker path', mapping.homeRelativeProbePath) + assertMappingIdentity(shellAbsolutePath, mapping) + + let reportedStartPath: unknown + try { + reportedStartPath = await realpathSftp(sftp, '.') + } catch (err) { + logRetainedShellPath( + 'REALPATH', + err instanceof Error ? err.message : String(err), + shellAbsolutePath + ) + return shellAbsolutePath + } + const startPath = normalizeSftpStartPath(reportedStartPath) + if (!startPath) { + logRetainedShellPath('REALPATH', 'unusable start directory', shellAbsolutePath) + return shellAbsolutePath + } + + const candidatePath = joinSftpStartPath(startPath, mapping.homeRelativePath) + if (candidatePath === shellAbsolutePath) { + return shellAbsolutePath + } + + const shellMarker = await probeMarkerPath(sftp, mapping.shellProbePath) + if (shellMarker.kind !== 'absent') { + if (shellMarker.kind === 'inconclusive') { + logRetainedShellPath('LSTAT', shellMarker.detail, shellAbsolutePath) + } + return shellAbsolutePath + } + + const candidateMarker = await probeMarkerPath( + sftp, + joinSftpStartPath(startPath, mapping.homeRelativeProbePath) + ) + if (candidateMarker.kind === 'present') { + console.log( + `[ssh-relay] SFTP namespace differs; transfer path: ${redactRelayInstallMarkerTokens(candidatePath)}` + ) + return candidatePath + } + if (candidateMarker.kind === 'inconclusive') { + logRetainedShellPath('LSTAT', candidateMarker.detail, shellAbsolutePath) + } + return shellAbsolutePath +} + +export type SftpTransferPathOptions = { + hostPlatform?: RemoteHostPlatform + sftpNamespace?: SftpNamespacePathMapping +} + +/** + * Namespace-resolve a transfer path only when a mapping was supplied and the host + * is not Windows, whose SFTP drive paths (`/C:/Users/...`) break the POSIX prefix + * contract. Callers without a mapping issue no REALPATH or LSTAT. + */ +export function resolveSftpTransferPathIfMapped( + sftp: SFTPWrapper, + shellAbsolutePath: string, + options?: SftpTransferPathOptions +): Promise { + const mapping = options?.sftpNamespace + if ( + !mapping || + (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) || + (!options?.hostPlatform && isRecognizableWindowsAbsolutePath(shellAbsolutePath)) + ) { + return Promise.resolve(shellAbsolutePath) + } + return resolveSftpTransferPath(sftp, shellAbsolutePath, mapping) +} + +function isRecognizableWindowsAbsolutePath(value: string): boolean { + return ( + /^[A-Za-z]:[\\/]/u.test(value) || + value.startsWith('\\\\') || + /^\/[A-Za-z]:\//u.test(value) || + /^\/\/[^/]/u.test(value) + ) +} diff --git a/src/main/ssh/sftp-upload.test.ts b/src/main/ssh/sftp-upload.test.ts index c87c2a1b7f5..199ee585adf 100644 --- a/src/main/ssh/sftp-upload.test.ts +++ b/src/main/ssh/sftp-upload.test.ts @@ -5,6 +5,7 @@ import { Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { SFTPWrapper } from 'ssh2' import { removeDirectorySftp, uploadBuffer, uploadDirectory, uploadFile } from './sftp-upload' +import { SSH_DIRECTORY_TRANSFER_LIMITS } from './ssh-directory-transfer-budget' function createWritable(): Writable { return new Writable({ @@ -18,9 +19,13 @@ function createSftpMock(): SFTPWrapper { return { mkdir: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)), createWriteStream: vi.fn(() => createWritable()), - readdir: vi.fn((_path: string, cb: (err?: Error | null, entries?: unknown[]) => void) => - cb(null, []) + opendir: vi.fn((path: string, cb: (err: Error | undefined, handle: Buffer) => void) => + cb(undefined, Buffer.from(path)) ), + readdir: vi.fn((_handle: Buffer, cb: (err?: Error | null, entries?: unknown[]) => void) => + cb(Object.assign(new Error('EOF'), { code: 1 }), []) + ), + close: vi.fn((_handle: Buffer, cb: (err?: Error | null) => void) => cb(null)), unlink: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)), rmdir: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)) } as unknown as SFTPWrapper @@ -94,6 +99,23 @@ describe('sftp-upload', () => { expect(sftp.createWriteStream).not.toHaveBeenCalled() }) + it('rejects excessive depth before creating remote entries', async () => { + const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) + let nested = localDir + for (let depth = 0; depth <= SSH_DIRECTORY_TRANSFER_LIMITS.maximumDepth; depth += 1) { + nested = join(nested, 'd') + await mkdir(nested) + } + const sftp = createSftpMock() + + await expect( + uploadDirectory(sftp, localDir, '/remote/assets', await realpath(localDir)) + ).rejects.toMatchObject({ reason: 'depth' }) + + expect(sftp.mkdir).not.toHaveBeenCalled() + expect(sftp.createWriteStream).not.toHaveBeenCalled() + }) + it('does not create the remote file when the local source is a symlink', async () => { const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) const targetPath = join(localDir, process.platform === 'win32' ? 'target-dir' : 'target.txt') @@ -116,8 +138,14 @@ describe('sftp-upload', () => { it('removes remote directory contents before removing the directory', async () => { const sftp = createSftpMock() - vi.mocked(sftp.readdir).mockImplementation((remotePath, cb) => { - const pathString = String(remotePath) + const readPaths = new Set() + vi.mocked(sftp.readdir).mockImplementation((handle, cb) => { + const pathString = String(handle) + if (readPaths.has(pathString)) { + cb(Object.assign(new Error('EOF'), { code: 1 }), [] as never) + return + } + readPaths.add(pathString) if (pathString === '/remote/assets') { cb(undefined, [ { filename: '.', attrs: { isDirectory: () => true } }, @@ -144,5 +172,6 @@ describe('sftp-upload', () => { expect(sftp.rmdir).toHaveBeenNthCalledWith(1, '/remote/assets/nested', expect.any(Function)) expect(sftp.unlink).toHaveBeenNthCalledWith(2, '/remote/assets/logo.png', expect.any(Function)) expect(sftp.rmdir).toHaveBeenNthCalledWith(2, '/remote/assets', expect.any(Function)) + expect(sftp.close).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts index 9ece3e2aaa9..144446866a8 100644 --- a/src/main/ssh/sftp-upload.ts +++ b/src/main/ssh/sftp-upload.ts @@ -1,8 +1,11 @@ import { constants } from 'node:fs' import type { ReadStream } from 'node:fs' -import { lstat, open, readdir, realpath } from 'node:fs/promises' +import { lstat, open, opendir, realpath } from 'node:fs/promises' import { isAbsolute, join as pathJoin, relative, sep } from 'node:path' import type { SFTPWrapper } from 'ssh2' +import { SshDirectoryTransferBudget } from './ssh-directory-transfer-budget' + +export { removeDirectorySftp } from './sftp-directory-removal' export function mkdirSftp( sftp: SFTPWrapper, @@ -125,6 +128,43 @@ export function uploadBuffer( }) } +export function writeStringViaSftp( + sftp: SFTPWrapper, + remotePath: string, + contents: string +): Promise { + return new Promise((resolve, reject) => { + const ws = sftp.createWriteStream(remotePath) + let settled = false + const cleanup = (): void => { + sftp.removeListener('error', onError) + ws.removeListener('close', onClose) + ws.removeListener('error', onError) + } + const onClose = (): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve() + } + const onError = (err: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(err) + } + // Why: prepend so a session error settles this write before a late-error swallower sees it. + sftp.prependOnceListener('error', onError) + ws.once('close', onClose) + ws.once('error', onError) + ws.end(contents) + }) +} + export async function uploadDirectory( sftp: SFTPWrapper, localDir: string, @@ -132,87 +172,65 @@ export async function uploadDirectory( rootRealPath = localDir, options?: { exclusive?: boolean } ): Promise { + const entries = await collectLocalUploadEntries(localDir, remoteDir, rootRealPath) + for (const entry of entries) { + await assertLocalUploadPathInsideRoot(rootRealPath, entry.localPath) + const statResult = await lstat(entry.localPath) + if (statResult.isSymbolicLink() || statResult.isDirectory() !== (entry.kind === 'directory')) { + throw new Error(`Upload source changed during transfer: ${entry.localPath}`) + } + await (entry.kind === 'directory' + ? mkdirSftp(sftp, entry.remotePath, { allowExisting: !options?.exclusive }) + : uploadFile(sftp, entry.localPath, entry.remotePath, { + exclusive: options?.exclusive + })) + } +} + +type LocalUploadEntry = { + kind: 'directory' | 'file' + localPath: string + remotePath: string +} + +async function collectLocalUploadEntries( + localDir: string, + remoteDir: string, + rootRealPath: string +): Promise { + const budget = new SshDirectoryTransferBudget() + const collected: LocalUploadEntry[] = [] + const pending = [{ localDir, remoteDir, depth: 0 }] await assertLocalUploadPathInsideRoot(rootRealPath, localDir) - const entries = await readdir(localDir, { withFileTypes: true }) - for (const entry of entries) { - const localPath = pathJoin(localDir, entry.name) - const remotePath = `${remoteDir}/${entry.name}` - await assertLocalUploadPathInsideRoot(rootRealPath, localPath) - const statResult = await lstat(localPath) + budget.recordPath(remoteDir, 0, { countEntry: false }) + budget.recordPath(localDir, 0, { countEntry: false }) - // Why: skip symlinks and special files (sockets, FIFOs, devices) to - // prevent following symlinks that could exfiltrate local files to the - // remote. The caller's pre-scan catches symlinks up-front, but this - // guard closes the TOCTOU gap if one is created between scan and upload. - if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { - continue - } - - if (statResult.isDirectory()) { - await mkdirSftp(sftp, remotePath, { allowExisting: !options?.exclusive }) - await uploadDirectory(sftp, localPath, remotePath, rootRealPath, options) - } else { - await uploadFile(sftp, localPath, remotePath, { exclusive: options?.exclusive }) + while (pending.length > 0) { + const current = pending.pop()! + const handle = await opendir(current.localDir) + try { + for await (const entry of handle) { + const localPath = pathJoin(current.localDir, entry.name) + const remotePath = `${current.remoteDir}/${entry.name}` + const depth = current.depth + 1 + budget.recordPath(remotePath, depth) + budget.recordPath(localPath, depth, { countEntry: false }) + await assertLocalUploadPathInsideRoot(rootRealPath, localPath) + const statResult = await lstat(localPath) + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { + continue + } + const kind = statResult.isDirectory() ? 'directory' : 'file' + collected.push({ kind, localPath, remotePath }) + if (kind === 'directory') { + pending.push({ localDir: localPath, remoteDir: remotePath, depth }) + } + } + } finally { + await handle.close().catch(() => undefined) } } -} - -export async function removeDirectorySftp(sftp: SFTPWrapper, remoteDir: string): Promise { - const entries = await readdirSftp(sftp, remoteDir) - const normalizedRemoteDir = remoteDir.replace(/\/+$/, '') - for (const entry of entries) { - if (entry.filename === '.' || entry.filename === '..') { - continue - } - const childPath = `${normalizedRemoteDir}/${entry.filename}` - await (entry.attrs?.isDirectory?.() - ? removeDirectorySftp(sftp, childPath) - : unlinkSftp(sftp, childPath)) - } - await rmdirSftp(sftp, remoteDir) -} - -type SftpDirectoryEntry = { - filename: string - attrs?: { - isDirectory?: () => boolean - } -} - -function readdirSftp(sftp: SFTPWrapper, remoteDir: string): Promise { - return new Promise((resolve, reject) => { - sftp.readdir(remoteDir, (err, entries) => { - if (err) { - reject(err) - return - } - resolve((entries ?? []) as SftpDirectoryEntry[]) - }) - }) -} - -function unlinkSftp(sftp: SFTPWrapper, remotePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.unlink(remotePath, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) - }) -} - -function rmdirSftp(sftp: SFTPWrapper, remoteDir: string): Promise { - return new Promise((resolve, reject) => { - sftp.rmdir(remoteDir, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) - }) + return collected } async function assertLocalUploadPathInsideRoot( diff --git a/src/main/ssh/ssh-agent-identity-filter.test.ts b/src/main/ssh/ssh-agent-identity-filter.test.ts index 5b341c7ce63..3a26dafdf5e 100644 --- a/src/main/ssh/ssh-agent-identity-filter.test.ts +++ b/src/main/ssh/ssh-agent-identity-filter.test.ts @@ -4,11 +4,11 @@ import { join } from 'node:path' const mocks = vi.hoisted(() => ({ createAgent: vi.fn(), parseKey: vi.fn(), - readFileSync: vi.fn() + readSshKeyFile: vi.fn() })) -vi.mock('fs', () => ({ - readFileSync: (...args: unknown[]) => mocks.readFileSync(...args) +vi.mock('./ssh-key-file', () => ({ + readSshKeyFile: (...args: unknown[]) => mocks.readSshKeyFile(...args) })) vi.mock('os', () => ({ @@ -50,13 +50,13 @@ describe('createIdentityFilteredAgent', () => { beforeEach(() => { mocks.createAgent.mockReset() mocks.parseKey.mockReset() - mocks.readFileSync.mockReset() + mocks.readSshKeyFile.mockReset() }) it('offers only agent identities matching configured identity files', async () => { const allowedKey = makeKey('allowed') const otherKey = makeKey('other') - mocks.readFileSync.mockReturnValue('ssh-ed25519 AAAA allowed') + mocks.readSshKeyFile.mockReturnValue('ssh-ed25519 AAAA allowed') mocks.parseKey.mockReturnValue(allowedKey) mocks.createAgent.mockReturnValue({ getIdentities: vi.fn((callback) => callback(undefined, [allowedKey, otherKey])), @@ -75,12 +75,12 @@ describe('createIdentityFilteredAgent', () => { }) expect(identities).toEqual([allowedKey]) - expect(mocks.readFileSync).toHaveBeenCalledWith(testHomePath('.ssh', 'work_key.pub')) + expect(mocks.readSshKeyFile).toHaveBeenCalledWith(testHomePath('.ssh', 'work_key.pub')) }) it('expands Windows-style configured identity file paths before filtering', async () => { const allowedKey = makeKey('allowed') - mocks.readFileSync.mockReturnValue('ssh-ed25519 AAAA allowed') + mocks.readSshKeyFile.mockReturnValue('ssh-ed25519 AAAA allowed') mocks.parseKey.mockReturnValue(allowedKey) mocks.createAgent.mockReturnValue({ getIdentities: vi.fn((callback) => callback(undefined, [allowedKey])), @@ -99,7 +99,7 @@ describe('createIdentityFilteredAgent', () => { }) expect(identities).toEqual([allowedKey]) - expect(mocks.readFileSync).toHaveBeenCalledWith(testHomePath('.ssh', 'work_key.pub')) + expect(mocks.readSshKeyFile).toHaveBeenCalledWith(testHomePath('.ssh', 'work_key.pub')) }) it('filters nested public key entries returned by ssh2 agents', async () => { @@ -107,7 +107,7 @@ describe('createIdentityFilteredAgent', () => { const otherKey = makeKey('other') const allowedEntry = { pubKey: { pubKey: allowedKey, comment: 'allowed' } } const otherEntry = { pubKey: { pubKey: otherKey, comment: 'other' } } - mocks.readFileSync.mockReturnValue('ssh-ed25519 AAAA allowed') + mocks.readSshKeyFile.mockReturnValue('ssh-ed25519 AAAA allowed') mocks.parseKey.mockReturnValue(allowedKey) mocks.createAgent.mockReturnValue({ getIdentities: vi.fn((callback) => callback(undefined, [allowedEntry, otherEntry])), @@ -129,7 +129,7 @@ describe('createIdentityFilteredAgent', () => { }) it('does not create a broad agent when configured identity keys cannot be parsed', () => { - mocks.readFileSync.mockReturnValue('not-a-key') + mocks.readSshKeyFile.mockReturnValue('not-a-key') mocks.parseKey.mockReturnValue(new Error('parse failed')) expect(createIdentityFilteredAgent('/tmp/agent.sock', ['~/.ssh/work_key'])).toBeUndefined() @@ -141,7 +141,7 @@ describe('createIdentityFilteredAgent', () => { const sign = vi.fn() const options = { hash: 'sha256' as const } const callback = vi.fn() - mocks.readFileSync.mockReturnValue('ssh-ed25519 AAAA allowed') + mocks.readSshKeyFile.mockReturnValue('ssh-ed25519 AAAA allowed') mocks.parseKey.mockReturnValue(allowedKey) mocks.createAgent.mockReturnValue({ getIdentities: vi.fn(), diff --git a/src/main/ssh/ssh-agent-identity-filter.ts b/src/main/ssh/ssh-agent-identity-filter.ts index 35a3c0a7721..cd980be05a0 100644 --- a/src/main/ssh/ssh-agent-identity-filter.ts +++ b/src/main/ssh/ssh-agent-identity-filter.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { BaseAgent, createAgent, @@ -10,6 +9,7 @@ import { type SigningRequestOptions } from 'ssh2' import { resolveSshConfigHomePath } from './ssh-config-path-expansion' +import { readSshKeyFile } from './ssh-key-file' type AgentPublicKey = ParsedKey | Buffer | string | PublicKeyEntry @@ -70,7 +70,7 @@ class IdentityFilteredAgent extends BaseAgent { function parseIdentityKeyFile(filePath: string): ParsedKey | undefined { try { - const parsed = utils.parseKey(readFileSync(filePath)) as ParsedKey | ParsedKey[] | Error + const parsed = utils.parseKey(readSshKeyFile(filePath)) as ParsedKey | ParsedKey[] | Error if (parsed instanceof Error) { return undefined } diff --git a/src/main/ssh/ssh-auth-resolution.ts b/src/main/ssh/ssh-auth-resolution.ts index a222ff2ee2d..2444d6bb619 100644 --- a/src/main/ssh/ssh-auth-resolution.ts +++ b/src/main/ssh/ssh-auth-resolution.ts @@ -1,9 +1,10 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { utils, type BaseAgent, type ParsedKey } from 'ssh2' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' import { createIdentityFilteredAgent } from './ssh-agent-identity-filter' import { resolveSshConfigHomePath } from './ssh-config-path-expansion' +import { readSshKeyFile } from './ssh-key-file' // Why: ssh2 only tries keys that are explicitly provided. Users with keys in // standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would @@ -26,7 +27,7 @@ export function findDefaultKeyFile(): PrivateKeyFile | undefined { if (!existsSync(resolved)) { continue } - const contents = readFileSync(resolved) + const contents = readSshKeyFile(resolved) return { path: keyPath, contents } } catch { continue @@ -96,7 +97,7 @@ function resolveExplicitPrivateKeyPath( function readPrivateKey(keyPath: string): PrivateKeyFile | undefined { try { const resolvedPath = resolveSshConfigHomePath(keyPath) - return { path: keyPath, contents: readFileSync(resolvedPath) } + return { path: keyPath, contents: readSshKeyFile(resolvedPath) } } catch { return undefined } diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index d17e756f75b..4f0faee8cc2 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -471,6 +471,25 @@ describe('SshChannelMultiplexer', () => { expect(internals.disposeHandlers).toHaveLength(0) }) + it('invokes every dispose handler when handlers unsubscribe themselves', () => { + const called: string[] = [] + let unsubscribeFirst = (): void => {} + let unsubscribeSecond = (): void => {} + unsubscribeFirst = mux.onDispose(() => { + called.push('first') + unsubscribeFirst() + }) + unsubscribeSecond = mux.onDispose(() => { + called.push('second') + unsubscribeSecond() + }) + + mux.dispose('connection_lost') + + expect(called).toEqual(['first', 'second']) + expect(getMuxInternals(mux).disposeHandlers).toHaveLength(0) + }) + it('does not retain handlers registered after dispose', () => { mux.dispose() diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 9886b724755..1550eb5e210 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -305,14 +305,14 @@ export class SshChannelMultiplexer { this.decoder.reset() this.transport.close?.() - for (const handler of this.disposeHandlers) { + // Why: stream handlers unsubscribe while failing; detach first so one cleanup cannot skip the next. + for (const handler of this.disposeHandlers.splice(0)) { try { handler(reason) } catch { // Don't let a handler error prevent other handlers from running } } - this.disposeHandlers.length = 0 } isDisposed(): boolean { diff --git a/src/main/ssh/ssh-config-bounded-glob.test.ts b/src/main/ssh/ssh-config-bounded-glob.test.ts new file mode 100644 index 00000000000..fe849184a19 --- /dev/null +++ b/src/main/ssh/ssh-config-bounded-glob.test.ts @@ -0,0 +1,75 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join, posix, win32 } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveBoundedSshConfigGlob } from './ssh-config-bounded-glob' + +const pathApi = process.platform === 'win32' ? win32 : posix +const tempRoots: string[] = [] +const SOURCE = readFileSync(new URL('./ssh-config-bounded-glob.ts', import.meta.url), 'utf8') + +function makeTempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-ssh-config-glob-')) + tempRoots.push(root) + return root +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('bounded SSH config include globbing', () => { + it('retains only the first 256 sorted matches without materializing every result', () => { + const root = makeTempRoot() + const includeDir = join(root, 'conf.d') + mkdirSync(includeDir) + for (let index = 0; index < 2_000; index += 1) { + writeFileSync(join(includeDir, `${String(index).padStart(4, '0')}.conf`), '') + } + + const result = resolveBoundedSshConfigGlob(join(includeDir, '*.conf'), pathApi, 256) + + expect(result.totalMatches).toBe(2_000) + expect(result.truncated).toBe(true) + expect(result.matches).toHaveLength(256) + expect(result.matches.map((filePath) => basename(filePath))).toEqual( + Array.from({ length: 256 }, (_, index) => `${String(index).padStart(4, '0')}.conf`) + ) + expect(SOURCE).not.toContain('globSync') + }) + + it('scans past nonmatches and preserves sorted order below the cap', () => { + const root = makeTempRoot() + const includeDir = join(root, 'conf.d') + mkdirSync(includeDir) + for (let index = 0; index < 300; index += 1) { + writeFileSync(join(includeDir, `${index}.txt`), '') + } + writeFileSync(join(includeDir, 'zeta.conf'), '') + writeFileSync(join(includeDir, 'alpha.conf'), '') + + const result = resolveBoundedSshConfigGlob(join(includeDir, '*.conf'), pathApi, 256) + + expect(result).toMatchObject({ totalMatches: 2, truncated: false }) + expect(result.matches.map((filePath) => basename(filePath))).toEqual([ + 'alpha.conf', + 'zeta.conf' + ]) + }) + + it('rejects path patterns deep enough to threaten the traversal stack', () => { + const pattern = pathApi.join( + pathApi.parse(process.cwd()).root, + ...Array.from({ length: 65 }, () => '*') + ) + + expect(resolveBoundedSshConfigGlob(pattern, pathApi, 256)).toEqual({ + matches: [], + totalMatches: 0, + truncated: true, + patternTooDeep: true + }) + }) +}) diff --git a/src/main/ssh/ssh-config-bounded-glob.ts b/src/main/ssh/ssh-config-bounded-glob.ts new file mode 100644 index 00000000000..04b8dbc7e97 --- /dev/null +++ b/src/main/ssh/ssh-config-bounded-glob.ts @@ -0,0 +1,124 @@ +import { existsSync, opendirSync, statSync } from 'node:fs' +import type { posix, win32 } from 'node:path' + +export type SshConfigPathApi = typeof posix | typeof win32 + +export type BoundedSshConfigGlobResult = { + matches: string[] + totalMatches: number + truncated: boolean + patternTooDeep: boolean +} + +const MAX_GLOB_PATTERN_SEGMENTS = 64 + +export function resolveBoundedSshConfigGlob( + absolutePattern: string, + pathApi: SshConfigPathApi, + matchLimit: number +): BoundedSshConfigGlobResult { + const root = pathApi.parse(absolutePattern).root + const segments = absolutePattern.slice(root.length).split(pathApi.sep).filter(Boolean) + if (segments.length > MAX_GLOB_PATTERN_SEGMENTS) { + return { matches: [], totalMatches: 0, truncated: true, patternTooDeep: true } + } + + const matches: string[] = [] + let totalMatches = 0 + const retainMatch = (matchedPath: string): void => { + totalMatches += 1 + insertSortedWithinLimit(matches, matchedPath, matchLimit) + } + + const visit = (directoryPath: string, segmentIndex: number): void => { + const segment = segments[segmentIndex] + const isLast = segmentIndex === segments.length - 1 + if (!hasGlobPattern(segment)) { + const nextPath = pathApi.join(directoryPath, segment) + if (isLast) { + if (existsSync(nextPath)) { + retainMatch(nextPath) + } + return + } + visit(nextPath, segmentIndex + 1) + return + } + + let directory: ReturnType + try { + directory = opendirSync(directoryPath) + } catch { + return + } + try { + for (let entry = directory.readSync(); entry; entry = directory.readSync()) { + if (!pathApi.matchesGlob(entry.name, segment)) { + continue + } + const nextPath = pathApi.join(directoryPath, entry.name) + if (isLast) { + retainMatch(nextPath) + continue + } + if (entry.isDirectory() || isDirectorySymlink(nextPath, entry.isSymbolicLink())) { + visit(nextPath, segmentIndex + 1) + } + } + } finally { + directory.closeSync() + } + } + + if (segments.length === 0) { + if (existsSync(root)) { + retainMatch(root) + } + } else { + visit(root, 0) + } + return { + matches, + totalMatches, + truncated: totalMatches > matchLimit, + patternTooDeep: false + } +} + +function insertSortedWithinLimit(target: string[], value: string, limit: number): void { + if (limit <= 0) { + return + } + let low = 0 + let high = target.length + while (low < high) { + const middle = (low + high) >>> 1 + if (target[middle].localeCompare(value) <= 0) { + low = middle + 1 + } else { + high = middle + } + } + if (low >= limit) { + return + } + target.splice(low, 0, value) + if (target.length > limit) { + target.pop() + } +} + +function isDirectorySymlink(filePath: string, isSymbolicLink: boolean): boolean { + if (!isSymbolicLink) { + return false + } + try { + return statSync(filePath).isDirectory() + } catch { + return false + } +} + +function hasGlobPattern(input: string): boolean { + return /[*?[]/.test(input) +} diff --git a/src/main/ssh/ssh-config-expansion-budget.ts b/src/main/ssh/ssh-config-expansion-budget.ts new file mode 100644 index 00000000000..49e3e5888d4 --- /dev/null +++ b/src/main/ssh/ssh-config-expansion-budget.ts @@ -0,0 +1,153 @@ +import { statSync } from 'node:fs' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' + +export const SSH_CONFIG_INCLUDE_LIMITS = { + expandedBytes: 16 * 1024 * 1024, + expandedLines: 200_000, + fileBytes: 1024 * 1024, + files: 1024, + globMatches: 256, + nestingDepth: 16, + sourceBytes: 16 * 1024 * 1024 +} as const + +export type SshConfigExpansionBudget = { + cache: Map + expandedBytes: number + expandedLines: number + fileCount: number + outputTruncated: boolean + sourceBytes: number + warnedLimits: Set +} + +export function createSshConfigExpansionBudget(): SshConfigExpansionBudget { + return { + cache: new Map(), + expandedBytes: 0, + expandedLines: 0, + fileCount: 0, + outputTruncated: false, + sourceBytes: 0, + warnedLimits: new Set() + } +} + +export function admitSshConfigIncludeDepth( + budget: SshConfigExpansionBudget, + activeDepth: number +): boolean { + if (activeDepth < SSH_CONFIG_INCLUDE_LIMITS.nestingDepth) { + return true + } + warnOnce( + budget, + 'nesting-depth', + `[ssh] SSH config Include nesting exceeds ${SSH_CONFIG_INCLUDE_LIMITS.nestingDepth}; skipping deeper files` + ) + return false +} + +export function appendSshConfigExpandedLine( + target: string[], + line: string, + budget: SshConfigExpansionBudget +): void { + const nextBytes = Buffer.byteLength(line, 'utf8') + (target.length > 0 ? 1 : 0) + if ( + budget.expandedLines >= SSH_CONFIG_INCLUDE_LIMITS.expandedLines || + budget.expandedBytes + nextBytes > SSH_CONFIG_INCLUDE_LIMITS.expandedBytes + ) { + budget.outputTruncated = true + warnOnce( + budget, + 'expanded-output', + `[ssh] Expanded SSH config exceeds ${SSH_CONFIG_INCLUDE_LIMITS.expandedBytes} bytes or ${SSH_CONFIG_INCLUDE_LIMITS.expandedLines} lines; truncating` + ) + return + } + target.push(line) + budget.expandedBytes += nextBytes + budget.expandedLines += 1 +} + +export function readSshConfigSourceFile( + filePath: string, + budget: SshConfigExpansionBudget +): string | null { + const cached = budget.cache.get(filePath) + if (cached !== undefined) { + return cached + } + if (budget.fileCount >= SSH_CONFIG_INCLUDE_LIMITS.files) { + warnOnce( + budget, + 'file-count', + `[ssh] SSH config Include file count exceeds ${SSH_CONFIG_INCLUDE_LIMITS.files}; skipping additional files` + ) + return null + } + const fileBytes = getReadableRegularFileBytes(filePath) + if (fileBytes === null || !hasSourceCapacity(budget, fileBytes)) { + return null + } + + try { + const content = readNodeFileSyncWithinLimit( + filePath, + SSH_CONFIG_INCLUDE_LIMITS.fileBytes + ).buffer.toString('utf-8') + const actualBytes = Buffer.byteLength(content, 'utf8') + if ( + actualBytes > SSH_CONFIG_INCLUDE_LIMITS.fileBytes || + !hasSourceCapacity(budget, actualBytes) + ) { + return null + } + budget.cache.set(filePath, content) + budget.fileCount += 1 + budget.sourceBytes += actualBytes + return content + } catch { + return null + } +} + +function hasSourceCapacity(budget: SshConfigExpansionBudget, fileBytes: number): boolean { + if (budget.sourceBytes + fileBytes <= SSH_CONFIG_INCLUDE_LIMITS.sourceBytes) { + return true + } + warnOnce( + budget, + 'source-bytes', + `[ssh] SSH config Include sources exceed ${SSH_CONFIG_INCLUDE_LIMITS.sourceBytes} bytes; skipping additional files` + ) + return false +} + +function getReadableRegularFileBytes(filePath: string): number | null { + try { + const stats = statSync(filePath) + if (!stats.isFile()) { + console.warn(`[ssh] Skipping SSH config include "${filePath}": not a regular file`) + return null + } + if (stats.size > SSH_CONFIG_INCLUDE_LIMITS.fileBytes) { + console.warn( + `[ssh] Skipping SSH config include "${filePath}": size ${stats.size} exceeds ${SSH_CONFIG_INCLUDE_LIMITS.fileBytes} bytes` + ) + return null + } + return stats.size + } catch { + return null + } +} + +function warnOnce(budget: SshConfigExpansionBudget, key: string, message: string): void { + if (budget.warnedLimits.has(key)) { + return + } + budget.warnedLimits.add(key) + console.warn(message) +} diff --git a/src/main/ssh/ssh-config-include-expander.ts b/src/main/ssh/ssh-config-include-expander.ts index eb1aba64c17..1f49c426fad 100644 --- a/src/main/ssh/ssh-config-include-expander.ts +++ b/src/main/ssh/ssh-config-include-expander.ts @@ -1,11 +1,20 @@ -import { existsSync, globSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { existsSync, realpathSync } from 'node:fs' import { homedir, hostname, userInfo } from 'node:os' import { posix, win32 } from 'node:path' +import { resolveBoundedSshConfigGlob, type SshConfigPathApi } from './ssh-config-bounded-glob' +import { + admitSshConfigIncludeDepth, + appendSshConfigExpandedLine, + createSshConfigExpansionBudget, + readSshConfigSourceFile, + SSH_CONFIG_INCLUDE_LIMITS, + type SshConfigExpansionBudget +} from './ssh-config-expansion-budget' -type PathApi = typeof posix | typeof win32 +type PathApi = SshConfigPathApi type IncludeExpansionContext = { - cache: Map + budget: SshConfigExpansionBudget home: string pathApi: PathApi rootDir: string @@ -14,8 +23,6 @@ type IncludeExpansionContext = { username: string } -const MAX_INCLUDE_GLOB_MATCHES = 256 -const MAX_INCLUDE_FILE_BYTES = 1024 * 1024 const TARGET_DEPENDENT_INCLUDE_TOKENS = new Set(['h', 'n', 'p', 'r', 'j', 'k', 'C']) export function expandSshConfigIncludes(configPath: string): string { @@ -25,7 +32,7 @@ export function expandSshConfigIncludes(configPath: string): string { const localHostname = hostname() const context: IncludeExpansionContext = { - cache: new Map(), + budget: createSshConfigExpansionBudget(), home, pathApi, rootDir: pathApi.dirname(configPath), @@ -34,69 +41,51 @@ export function expandSshConfigIncludes(configPath: string): string { username: currentUser } - return expandSshConfigFile(configPath, context, []).join('\n') + const expandedLines: string[] = [] + expandSshConfigFile(configPath, context, [], expandedLines) + return expandedLines.join('\n') } function expandSshConfigFile( filePath: string, context: IncludeExpansionContext, - activeStack: string[] -): string[] { + activeStack: string[], + expandedLines: string[] +): void { const canonicalPath = getCanonicalPath(filePath) if (!canonicalPath || activeStack.includes(canonicalPath)) { - return [] + return + } + if (!admitSshConfigIncludeDepth(context.budget, activeStack.length)) { + return } - const rawContent = readCachedFile(canonicalPath, context) + const rawContent = readSshConfigSourceFile(canonicalPath, context.budget) if (rawContent === null) { - return [] + return } - const expandedLines: string[] = [] const nextStack = [...activeStack, canonicalPath] for (const line of rawContent.split(/\r?\n/)) { + if (context.budget.outputTruncated) { + return + } const includeArgs = parseIncludeDirective(line) if (!includeArgs) { - expandedLines.push(line) + appendSshConfigExpandedLine(expandedLines, line, context.budget) continue } for (const includeArg of includeArgs) { for (const matchedPath of resolveIncludePaths(includeArg, context)) { - appendExpandedLines(expandedLines, expandSshConfigFile(matchedPath, context, nextStack)) + expandSshConfigFile(matchedPath, context, nextStack, expandedLines) + if (context.budget.outputTruncated) { + return + } } } } - - return expandedLines -} - -function appendExpandedLines(target: string[], lines: readonly string[]): void { - // Why: SSH config includes are user-controlled files, and a large included - // file can exceed the JavaScript call argument limit when spread into push. - for (const line of lines) { - target.push(line) - } -} - -function readCachedFile(filePath: string, context: IncludeExpansionContext): string | null { - const cached = context.cache.get(filePath) - if (cached !== undefined) { - return cached - } - - if (!isReadableRegularFile(filePath)) { - return null - } - - try { - const content = readFileSync(filePath, 'utf-8') - context.cache.set(filePath, content) - return content - } catch { - return null - } } function parseIncludeDirective(line: string): string[] | null { @@ -168,18 +157,23 @@ function resolveIncludePaths(pattern: string, context: IncludeExpansionContext): const absolutePattern = resolveIncludePatternPath(withTokens, context) if (hasGlobPattern(absolutePattern)) { - try { - const matches = globSync(absolutePattern).sort((left, right) => left.localeCompare(right)) - if (matches.length > MAX_INCLUDE_GLOB_MATCHES) { - console.warn( - `[ssh] Include pattern "${absolutePattern}" matched ${matches.length} files; processing first ${MAX_INCLUDE_GLOB_MATCHES}` - ) - return matches.slice(0, MAX_INCLUDE_GLOB_MATCHES) - } - return matches - } catch { + const result = resolveBoundedSshConfigGlob( + absolutePattern, + context.pathApi, + SSH_CONFIG_INCLUDE_LIMITS.globMatches + ) + if (result.patternTooDeep) { + console.warn( + `[ssh] Include pattern "${absolutePattern}" has too many path segments; skipping` + ) return [] } + if (result.truncated) { + console.warn( + `[ssh] Include pattern "${absolutePattern}" matched ${result.totalMatches} files; processing first ${SSH_CONFIG_INCLUDE_LIMITS.globMatches}` + ) + } + return result.matches } return existsSync(absolutePattern) ? [absolutePattern] : [] @@ -293,25 +287,6 @@ function getCanonicalPath(filePath: string): string | null { } } -function isReadableRegularFile(filePath: string): boolean { - try { - const stats = statSync(filePath) - if (!stats.isFile()) { - console.warn(`[ssh] Skipping SSH config include "${filePath}": not a regular file`) - return false - } - if (stats.size > MAX_INCLUDE_FILE_BYTES) { - console.warn( - `[ssh] Skipping SSH config include "${filePath}": size ${stats.size} exceeds ${MAX_INCLUDE_FILE_BYTES} bytes` - ) - return false - } - return true - } catch { - return false - } -} - function getCurrentUid(): string | undefined { try { const info = userInfo() diff --git a/src/main/ssh/ssh-config-include-expansion-bounds.test.ts b/src/main/ssh/ssh-config-include-expansion-bounds.test.ts new file mode 100644 index 00000000000..d1eda95eeb0 --- /dev/null +++ b/src/main/ssh/ssh-config-include-expansion-bounds.test.ts @@ -0,0 +1,82 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + appendSshConfigExpandedLine, + createSshConfigExpansionBudget, + readSshConfigSourceFile, + SSH_CONFIG_INCLUDE_LIMITS +} from './ssh-config-expansion-budget' +import { expandSshConfigIncludes } from './ssh-config-include-expander' + +const tempRoots: string[] = [] + +function makeTempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-ssh-config-bounds-')) + tempRoots.push(root) + return root +} + +afterEach(() => { + vi.restoreAllMocks() + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('SSH config include expansion bounds', () => { + it('stops recursive includes at 16 active files', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const root = makeTempRoot() + for (let depth = 0; depth < 20; depth += 1) { + writeFileSync( + join(root, `${depth}.conf`), + `Host depth-${depth}\n${depth < 19 ? `Include ${depth + 1}.conf\n` : ''}` + ) + } + + const expanded = expandSshConfigIncludes(join(root, '0.conf')) + + expect(expanded).toContain('Host depth-15') + expect(expanded).not.toContain('Host depth-16') + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('nesting exceeds 16')) + }) + + it('admits exactly 1,024 unique files and 16 MiB of source bytes', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const root = makeTempRoot() + const firstPath = join(root, 'first.conf') + const secondPath = join(root, 'second.conf') + writeFileSync(firstPath, 'a') + writeFileSync(secondPath, 'b') + const fileBudget = createSshConfigExpansionBudget() + fileBudget.fileCount = SSH_CONFIG_INCLUDE_LIMITS.files - 1 + + expect(readSshConfigSourceFile(firstPath, fileBudget)).toBe('a') + expect(readSshConfigSourceFile(secondPath, fileBudget)).toBeNull() + + const byteBudget = createSshConfigExpansionBudget() + byteBudget.sourceBytes = SSH_CONFIG_INCLUDE_LIMITS.sourceBytes - 1 + expect(readSshConfigSourceFile(firstPath, byteBudget)).toBe('a') + expect(readSshConfigSourceFile(secondPath, byteBudget)).toBeNull() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('file count exceeds 1024')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('sources exceed 16777216 bytes')) + }) + + it('caps expanded output at 16 MiB or 200,000 lines', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const byteBudget = createSshConfigExpansionBudget() + byteBudget.expandedBytes = SSH_CONFIG_INCLUDE_LIMITS.expandedBytes - 1 + const byteLines = [''] + appendSshConfigExpandedLine(byteLines, '', byteBudget) + expect(byteBudget.expandedBytes).toBe(SSH_CONFIG_INCLUDE_LIMITS.expandedBytes) + appendSshConfigExpandedLine(byteLines, '', byteBudget) + expect(byteBudget.outputTruncated).toBe(true) + + const lineBudget = createSshConfigExpansionBudget() + lineBudget.expandedLines = SSH_CONFIG_INCLUDE_LIMITS.expandedLines + appendSshConfigExpandedLine([], 'Host overflow', lineBudget) + expect(lineBudget.outputTruncated).toBe(true) + }) +}) diff --git a/src/main/ssh/ssh-config-loader-regression.test.ts b/src/main/ssh/ssh-config-loader-regression.test.ts index 089ee9d99bb..a62d58c87ab 100644 --- a/src/main/ssh/ssh-config-loader-regression.test.ts +++ b/src/main/ssh/ssh-config-loader-regression.test.ts @@ -8,6 +8,7 @@ afterEach(() => { vi.resetModules() vi.doUnmock('fs') vi.doUnmock('os') + vi.doUnmock('../../shared/memory-safety/node-bounded-file-reader') }) function normalizeWin(value: string): string { @@ -24,6 +25,24 @@ function platformSshPath(home: string, relativePath: string): string { : `${home}/${relativePath}` } +function mockDirectory(names: string[]) { + let index = 0 + return { + readSync: () => { + const name = names[index] + index += 1 + return name === undefined + ? null + : { + name, + isDirectory: () => false, + isSymbolicLink: () => false + } + }, + closeSync: () => {} + } +} + async function mockOs( home: string, username = 'testuser', @@ -42,6 +61,18 @@ async function mockOs( } async function loadUserSshConfig() { + vi.doMock('../../shared/memory-safety/node-bounded-file-reader', async () => { + const fs = await import('node:fs') + return { + readNodeFileSyncWithinLimit: (filePath: string, maxBytes: number) => { + const buffer = Buffer.from(fs.readFileSync(filePath, 'utf8')) + if (buffer.byteLength > maxBytes) { + throw new Error('File too large') + } + return { buffer, stats: fs.statSync(filePath) } + } + } + }) const mod = await import('./ssh-config-parser') return mod.loadUserSshConfig() } @@ -77,13 +108,12 @@ describe('loadUserSshConfig regressions', () => { return { ...actual, existsSync: (filePath: string) => files.has(normalizeWin(filePath)), - globSync: (pattern: string) => - normalizeWin(pattern) === normalizeWin('C:/Users/Test User/.ssh/conf.d/*.conf') - ? [ - normalizeWin('C:/Users/Test User/.ssh/conf.d/alpha.conf'), - normalizeWin('C:/Users/Test User/.ssh/conf.d/zeta.conf') - ] - : [], + opendirSync: (directoryPath: string) => { + if (normalizeWin(directoryPath) !== normalizeWin('C:/Users/Test User/.ssh/conf.d')) { + throw new Error(`ENOENT: ${directoryPath}`) + } + return mockDirectory(['zeta.conf', 'alpha.conf']) + }, readFileSync: (filePath: string) => { const content = files.get(normalizeWin(filePath)) if (content === undefined) { @@ -205,7 +235,8 @@ describe('loadUserSshConfig regressions', () => { ...actual, existsSync: (filePath: string) => filePath === configPath || includePaths.includes(filePath), - globSync: () => [...includePaths].toReversed(), + opendirSync: () => + mockDirectory(includePaths.map((filePath) => filePath.split(/[\\/]/).at(-1)!)), readFileSync: (filePath: string) => { if (filePath === configPath) { return 'Include conf.d/*.conf\n' diff --git a/src/main/ssh/ssh-connection-generation.test.ts b/src/main/ssh/ssh-connection-generation.test.ts index a6c995bcab2..3216794116a 100644 --- a/src/main/ssh/ssh-connection-generation.test.ts +++ b/src/main/ssh/ssh-connection-generation.test.ts @@ -1,9 +1,15 @@ import { afterEach, describe, expect, it } from 'vitest' import { + _internals, + MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES, + MAX_SSH_CONNECTION_GENERATION_RETAINED_TARGET_ID_BYTES, + MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES, + MAX_TRACKED_SSH_CONNECTION_GENERATIONS, advanceSshConnectionGeneration, assertSshMutationExpectation, getSshConnectionGeneration, resetSshConnectionGenerations, + retainSshConnectionGeneration, setSshConnectionGeneration } from './ssh-connection-generation' @@ -15,23 +21,31 @@ describe('SSH connection generation session scope', () => { it('does not reuse a target token when a restarted HUB reaches the same counter', () => { resetSshConnectionGenerations(41) - const beforeRestart = advanceSshConnectionGeneration('ssh-a') + const beforeRestartLease = retainSshConnectionGeneration('ssh-a') + const beforeRestart = getSshConnectionGeneration('ssh-a') resetSshConnectionGenerations(42) - const afterRestart = advanceSshConnectionGeneration('ssh-a') + const afterRestartLease = retainSshConnectionGeneration('ssh-a') + const afterRestart = getSshConnectionGeneration('ssh-a') expect(afterRestart).not.toBe(beforeRestart) expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', beforeRestart)).toThrow( 'SSH connection changed; refresh and try again' ) expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', afterRestart)).not.toThrow() + beforeRestartLease.release() + afterRestartLease.release() }) - it('keeps target counters independent within one HUB session', () => { + it('allocates globally unique target tokens within one HUB session', () => { resetSshConnectionGenerations(7) + const first = retainSshConnectionGeneration('ssh-a') + const second = retainSshConnectionGeneration('ssh-b') - expect(advanceSshConnectionGeneration('ssh-a')).toBe(advanceSshConnectionGeneration('ssh-b')) - expect(getSshConnectionGeneration('ssh-a')).toBe(getSshConnectionGeneration('ssh-b')) + expect(getSshConnectionGeneration('ssh-a')).not.toBe(getSshConnectionGeneration('ssh-b')) + + first.release() + second.release() }) it('rejects an SSH execution-host expectation when direct IPC resolves locally', () => { @@ -46,45 +60,165 @@ describe('SSH connection generation session scope', () => { ) }) - it('rolls the session scope after counter exhaustion and keeps rotating', () => { + it('rejects an unretained target even when its expected token equals the session base', () => { resetSshConnectionGenerations(7) - const exhaustedGeneration = 8 * SESSION_COUNTER_STRIDE - 1 - setSshConnectionGeneration('ssh-a', exhaustedGeneration) - const rolledGeneration = advanceSshConnectionGeneration('ssh-a') - - expect(rolledGeneration).toBe(8 * SESSION_COUNTER_STRIDE + 1) - expect(advanceSshConnectionGeneration('ssh-a')).toBe(rolledGeneration + 1) - expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', exhaustedGeneration)).toThrow( - 'SSH connection changed; refresh and try again' - ) + expect(() => + assertSshMutationExpectation('ssh-a', 'ssh-a', 7 * SESSION_COUNTER_STRIDE) + ).toThrow('SSH connection changed; refresh and try again') }) - it('invalidates other targets when exhaustion rolls the session scope', () => { + it('rotates allocation scopes without invalidating unrelated retained targets', () => { resetSshConnectionGenerations(11) - const otherTargetGeneration = advanceSshConnectionGeneration('ssh-b') + const otherLease = retainSshConnectionGeneration('ssh-b') + const otherTargetGeneration = getSshConnectionGeneration('ssh-b') + const rotatingLease = retainSshConnectionGeneration('ssh-a') setSshConnectionGeneration('ssh-a', 12 * SESSION_COUNTER_STRIDE - 1) - const rolledGeneration = advanceSshConnectionGeneration('ssh-a') + const rotatedGeneration = advanceSshConnectionGeneration('ssh-a') - expect(getSshConnectionGeneration('ssh-b')).toBe(12 * SESSION_COUNTER_STRIDE) - expect(rolledGeneration).toBe(12 * SESSION_COUNTER_STRIDE + 1) - expect(() => assertSshMutationExpectation('ssh-b', 'ssh-b', otherTargetGeneration)).toThrow( - 'SSH connection changed; refresh and try again' - ) - expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', rolledGeneration)).not.toThrow() + expect(rotatedGeneration).toBe(12 * SESSION_COUNTER_STRIDE + 1) + expect(getSshConnectionGeneration('ssh-b')).toBe(otherTargetGeneration) + expect(() => + assertSshMutationExpectation('ssh-b', 'ssh-b', otherTargetGeneration) + ).not.toThrow() + otherLease.release() + rotatingLease.release() }) - it('wraps the maximum safe numeric scope without reusing it', () => { + it('wraps the maximum numeric scope without reusing an allocated token', () => { resetSshConnectionGenerations(MAX_SESSION_SCOPE) + const lease = retainSshConnectionGeneration('ssh-a') setSshConnectionGeneration('ssh-a', Number.MAX_SAFE_INTEGER) const rolledGeneration = advanceSshConnectionGeneration('ssh-a') expect(rolledGeneration).toBe(1) expect(Number.isSafeInteger(rolledGeneration)).toBe(true) - expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', Number.MAX_SAFE_INTEGER)).toThrow( + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', rolledGeneration!)).not.toThrow() + lease.release() + }) + + it('rejects only the 4,097th target without changing active or in-flight tokens', () => { + resetSshConnectionGenerations(7) + const leases = Array.from({ length: MAX_TRACKED_SSH_CONNECTION_GENERATIONS }, (_, index) => + retainSshConnectionGeneration(`ssh-${index}`) + ) + const activeGeneration = getSshConnectionGeneration('ssh-0') + const inFlightGeneration = getSshConnectionGeneration( + `ssh-${MAX_TRACKED_SSH_CONNECTION_GENERATIONS - 1}` + ) + + expect(() => retainSshConnectionGeneration('ssh-overflow')).toThrow( + 'SSH connection generation target capacity exhausted' + ) + expect(getSshConnectionGeneration('ssh-0')).toBe(activeGeneration) + expect(getSshConnectionGeneration(`ssh-${MAX_TRACKED_SSH_CONNECTION_GENERATIONS - 1}`)).toBe( + inFlightGeneration + ) + expect(() => assertSshMutationExpectation('ssh-0', 'ssh-0', activeGeneration)).not.toThrow() + + leases[1].release() + const overflowLease = retainSshConnectionGeneration('ssh-overflow') + expect(getSshConnectionGeneration('ssh-0')).toBe(activeGeneration) + for (const lease of leases) { + lease.release() + } + overflowLease.release() + expect(_internals.evidenceForTest()).toEqual({ + retainedGenerationLeases: 0, + retainedTargetIdBytes: 0, + trackedTargets: 0 + }) + }) + + it('keeps an entry until its final ownership lease releases', () => { + const activeLease = retainSshConnectionGeneration('ssh-a') + const inFlightLease = retainSshConnectionGeneration('ssh-a') + const generation = getSshConnectionGeneration('ssh-a') + + activeLease.release() + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', generation)).not.toThrow() + + inFlightLease.release() + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', generation)).toThrow( 'SSH connection changed; refresh and try again' ) }) + + it('never reuses a released target token during sequential churn', () => { + let previous = 0 + for (let index = 0; index < 10_000; index += 1) { + const lease = retainSshConnectionGeneration(`ssh-${index}`) + const generation = getSshConnectionGeneration(`ssh-${index}`) + expect(generation).toBeGreaterThan(previous) + previous = generation + lease.release() + } + + expect(_internals.evidenceForTest()).toEqual({ + retainedGenerationLeases: 0, + retainedTargetIdBytes: 0, + trackedTargets: 0 + }) + }) + + it('bounds outstanding leases for one hung target', () => { + const leases = Array.from({ length: MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES }, () => + retainSshConnectionGeneration('ssh-a') + ) + const generation = getSshConnectionGeneration('ssh-a') + + expect(() => retainSshConnectionGeneration('ssh-a')).toThrow( + 'SSH connection generation lease capacity exhausted' + ) + expect(getSshConnectionGeneration('ssh-a')).toBe(generation) + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', generation)).not.toThrow() + expect(_internals.evidenceForTest()).toEqual({ + retainedGenerationLeases: MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES, + retainedTargetIdBytes: 5, + trackedTargets: 1 + }) + + for (const lease of leases) { + lease.release() + } + expect(_internals.evidenceForTest()).toEqual({ + retainedGenerationLeases: 0, + retainedTargetIdBytes: 0, + trackedTargets: 0 + }) + }) + + it('bounds individual and aggregate retained target-id bytes', () => { + expect(() => + retainSshConnectionGeneration('x'.repeat(MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES + 1)) + ).toThrow('SSH connection generation target id exceeds') + + const leases = Array.from( + { + length: + MAX_SSH_CONNECTION_GENERATION_RETAINED_TARGET_ID_BYTES / + MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES + }, + (_, index) => { + const prefix = `${index}:` + return retainSshConnectionGeneration( + prefix + 'x'.repeat(MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES - prefix.length) + ) + } + ) + expect(() => retainSshConnectionGeneration('overflow')).toThrow( + 'SSH connection generation target capacity exhausted' + ) + + for (const lease of leases) { + lease.release() + } + expect(_internals.evidenceForTest()).toEqual({ + retainedGenerationLeases: 0, + retainedTargetIdBytes: 0, + trackedTargets: 0 + }) + }) }) diff --git a/src/main/ssh/ssh-connection-generation.ts b/src/main/ssh/ssh-connection-generation.ts index 96f1ae04c83..aa38d3eb23a 100644 --- a/src/main/ssh/ssh-connection-generation.ts +++ b/src/main/ssh/ssh-connection-generation.ts @@ -1,77 +1,184 @@ import { randomBytes } from 'node:crypto' import { toSshExecutionHostId } from '../../shared/execution-host' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' const SESSION_COUNTER_BITS = 13 const SESSION_COUNTER_STRIDE = 2 ** SESSION_COUNTER_BITS const MAX_SESSION_SCOPE = 2 ** (53 - SESSION_COUNTER_BITS) - 1 +export const MAX_TRACKED_SSH_CONNECTION_GENERATIONS = 4_096 +export const MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES = 8_192 +export const MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES = 64 * 1024 +export const MAX_SSH_CONNECTION_GENERATION_RETAINED_TARGET_ID_BYTES = 4 * 1024 * 1024 + +type SshConnectionGenerationEntry = { + generation: number + leaseCount: number + targetIdBytes: number +} + +export type SshConnectionGenerationLease = { + release: () => void +} function createSessionScope(): number { return randomBytes(5).readUIntBE(0, 5) } -let sessionGenerationBase = 0 +let allocationSessionBase = 0 +let allocationSessionCounter = 0 let sessionInitialized = false -const connectionGenerationByTarget = new Map() -const usedSessionScopes = new Set() +let allocatedSessionScopeCount = 1 +let generationRegistryEpoch = 0 +let retainedGenerationLeases = 0 +let retainedTargetIdBytes = 0 +const connectionGenerationByTarget = new Map() -function assertGenerationInCurrentSession(generation: number): void { - if ( - !Number.isSafeInteger(generation) || - generation < sessionGenerationBase || - generation - sessionGenerationBase >= SESSION_COUNTER_STRIDE - ) { +function rotateAllocationSessionScope(): void { + if (allocatedSessionScopeCount >= MAX_SESSION_SCOPE + 1) { throw new Error('SSH connection generation exhausted for this runtime session') } + const nextSessionScope = + (allocationSessionBase / SESSION_COUNTER_STRIDE + 1) % (MAX_SESSION_SCOPE + 1) + allocationSessionBase = nextSessionScope * SESSION_COUNTER_STRIDE + allocationSessionCounter = 0 + allocatedSessionScopeCount += 1 +} + +function allocateGeneration(): number { + if (allocationSessionCounter >= SESSION_COUNTER_STRIDE - 1) { + rotateAllocationSessionScope() + } + allocationSessionCounter += 1 + const generation = allocationSessionBase + allocationSessionCounter + if (!Number.isSafeInteger(generation)) { + throw new Error('SSH connection generation exhausted for this runtime session') + } + return generation +} + +function measureTargetId(targetId: string): number { + const measured = measureUtf8ByteLength(targetId, { + stopAfterBytes: MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES + }) + if (measured.exceededLimit) { + throw new Error( + `SSH connection generation target id exceeds ${MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES} UTF-8 bytes` + ) + } + return measured.byteLength +} + +export function retainSshConnectionGeneration(targetId: string): SshConnectionGenerationLease { + if (retainedGenerationLeases >= MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES) { + throw new Error('SSH connection generation lease capacity exhausted') + } + let entry = connectionGenerationByTarget.get(targetId) + if (!entry) { + const targetIdBytes = measureTargetId(targetId) + if ( + connectionGenerationByTarget.size >= MAX_TRACKED_SSH_CONNECTION_GENERATIONS || + retainedTargetIdBytes + targetIdBytes > MAX_SSH_CONNECTION_GENERATION_RETAINED_TARGET_ID_BYTES + ) { + throw new Error('SSH connection generation target capacity exhausted') + } + entry = { + generation: allocateGeneration(), + leaseCount: 0, + targetIdBytes + } + connectionGenerationByTarget.set(targetId, entry) + retainedTargetIdBytes += targetIdBytes + } + entry.leaseCount += 1 + retainedGenerationLeases += 1 + const retainedEntry = entry + const retainedEpoch = generationRegistryEpoch + let retained = true + return { + release: () => { + if (!retained) { + return + } + retained = false + retainedEntry.leaseCount -= 1 + if (generationRegistryEpoch === retainedEpoch) { + retainedGenerationLeases -= 1 + } + if ( + retainedEntry.leaseCount === 0 && + connectionGenerationByTarget.get(targetId) === retainedEntry + ) { + connectionGenerationByTarget.delete(targetId) + retainedTargetIdBytes -= retainedEntry.targetIdBytes + } + } + } } export function getSshConnectionGeneration(targetId: string): number { - return connectionGenerationByTarget.get(targetId) ?? sessionGenerationBase + return connectionGenerationByTarget.get(targetId)?.generation ?? allocationSessionBase } export function initializeSshConnectionGenerationSession(): void { if (sessionInitialized) { return } - const sessionScope = createSessionScope() - // Why: randomize the process scope so a replacement HUB does not predictably reuse the prior target/counter token. - sessionGenerationBase = sessionScope * SESSION_COUNTER_STRIDE - usedSessionScopes.add(sessionScope) + allocationSessionBase = createSessionScope() * SESSION_COUNTER_STRIDE + allocationSessionCounter = 0 + allocatedSessionScopeCount = 1 sessionInitialized = true } -export function advanceSshConnectionGeneration(targetId: string): number { - let next = getSshConnectionGeneration(targetId) + 1 - if (next - sessionGenerationBase >= SESSION_COUNTER_STRIDE) { - let nextSessionScope = - (sessionGenerationBase / SESSION_COUNTER_STRIDE + 1) % (MAX_SESSION_SCOPE + 1) - while (usedSessionScopes.has(nextSessionScope)) { - nextSessionScope = (nextSessionScope + 1) % (MAX_SESSION_SCOPE + 1) - } - usedSessionScopes.add(nextSessionScope) - sessionGenerationBase = nextSessionScope * SESSION_COUNTER_STRIDE - // Why: changing the scope must revoke tokens for every target, not only the target that exhausted its counter. - connectionGenerationByTarget.clear() - next = sessionGenerationBase + 1 +export function advanceSshConnectionGeneration(targetId: string): number | null { + const entry = connectionGenerationByTarget.get(targetId) + if (!entry) { + return null } - assertGenerationInCurrentSession(next) - connectionGenerationByTarget.set(targetId, next) - return next + entry.generation = allocateGeneration() + return entry.generation } export function setSshConnectionGeneration(targetId: string, generation: number): void { - assertGenerationInCurrentSession(generation) - connectionGenerationByTarget.set(targetId, generation) + const generationOffset = generation - allocationSessionBase + if ( + !Number.isSafeInteger(generation) || + generationOffset < 0 || + generationOffset >= SESSION_COUNTER_STRIDE + ) { + throw new Error('SSH connection generation exhausted for this runtime session') + } + let entry = connectionGenerationByTarget.get(targetId) + if (!entry) { + const targetIdBytes = measureTargetId(targetId) + if ( + retainedGenerationLeases >= MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES || + connectionGenerationByTarget.size >= MAX_TRACKED_SSH_CONNECTION_GENERATIONS || + retainedTargetIdBytes + targetIdBytes > MAX_SSH_CONNECTION_GENERATION_RETAINED_TARGET_ID_BYTES + ) { + throw new Error('SSH connection generation target capacity exhausted') + } + entry = { generation, leaseCount: 1, targetIdBytes } + connectionGenerationByTarget.set(targetId, entry) + retainedGenerationLeases += 1 + retainedTargetIdBytes += targetIdBytes + } else { + entry.generation = generation + } + allocationSessionCounter = Math.max(allocationSessionCounter, generationOffset) } export function resetSshConnectionGenerations(sessionScope = 0): void { if (!Number.isSafeInteger(sessionScope) || sessionScope < 0 || sessionScope > MAX_SESSION_SCOPE) { throw new Error('Invalid SSH connection generation session scope') } - sessionGenerationBase = sessionScope * SESSION_COUNTER_STRIDE + allocationSessionBase = sessionScope * SESSION_COUNTER_STRIDE + allocationSessionCounter = 0 sessionInitialized = true + allocatedSessionScopeCount = 1 + generationRegistryEpoch += 1 + retainedGenerationLeases = 0 + retainedTargetIdBytes = 0 connectionGenerationByTarget.clear() - usedSessionScopes.clear() - usedSessionScopes.add(sessionScope) } export function assertSshMutationExpectation( @@ -91,11 +198,27 @@ export function assertSshMutationExpectation( } return } + const current = connectionGenerationByTarget.get(connectionId) if ( expectedTargetId !== connectionId || expectedGeneration === undefined || - expectedGeneration !== getSshConnectionGeneration(connectionId) + !current || + expectedGeneration !== current.generation ) { throw new Error('SSH connection changed; refresh and try again') } } + +export const _internals = { + evidenceForTest(): { + retainedGenerationLeases: number + retainedTargetIdBytes: number + trackedTargets: number + } { + return { + retainedGenerationLeases, + retainedTargetIdBytes, + trackedTargets: connectionGenerationByTarget.size + } + } +} diff --git a/src/main/ssh/ssh-connection-sftp-namespace.test.ts b/src/main/ssh/ssh-connection-sftp-namespace.test.ts new file mode 100644 index 00000000000..dd5730aa210 --- /dev/null +++ b/src/main/ssh/ssh-connection-sftp-namespace.test.ts @@ -0,0 +1,471 @@ +// Why: namespace resolution has to happen on the very session that transfers, and +// only for the two relay-install writes — a leak into other transfers or into the +// system-SSH/Windows branches would silently retarget unrelated file operations. + +import { EventEmitter } from 'node:events' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('ssh2', () => ({ + BaseAgent: class {}, + Client: class {}, + createAgent: vi.fn(), + utils: { parseKey: vi.fn() } +})) + +vi.mock('./ssh-system-fallback', () => ({ + getOrcaControlSocketPath: vi.fn().mockReturnValue(null), + spawnSystemSsh: vi.fn(), + spawnSystemSshCommand: vi.fn(), + downloadFileViaSystemSsh: vi.fn().mockResolvedValue(undefined), + uploadDirectoryViaSystemSsh: vi.fn().mockResolvedValue(undefined), + uploadFileViaSystemSsh: vi.fn().mockResolvedValue(undefined), + writeBufferViaSystemSsh: vi.fn().mockResolvedValue(undefined), + writeFileViaSystemSsh: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-control-socket', () => ({ removeControlSocketPath: vi.fn() })) +vi.mock('./ssh-config-parser', () => ({ resolveWithSshG: vi.fn().mockResolvedValue(null) })) + +import { SshConnection } from './ssh-connection' +import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { isSshSessionLimitError } from './ssh-session-limit-error' +import { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback' +import type { SshTarget } from '../../shared/ssh-types' + +const SHELL_HOME = '/var/services/homes/alice' +const SFTP_HOME = '/homes/alice' +const RELAY_DIR = '.orca-remote/relay-0.1.0+hash' +const MARKER = '.install-lock/.sftp-namespace-cafebabe' +const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_DIR}` + +const namespace: SftpNamespacePathMapping = { + homeRelativePath: RELAY_DIR, + shellProbePath: `${SHELL_RELAY_DIR}/${MARKER}`, + homeRelativeProbePath: `${RELAY_DIR}/${MARKER}` +} + +function fileNamespace(fileName: string): SftpNamespacePathMapping { + return { ...namespace, homeRelativePath: `${RELAY_DIR}/${fileName}` } +} + +type FakeSftp = EventEmitter & { + realpathCalls: string[] + lstatCalls: string[] + writtenPaths: string[] + mkdirPaths: string[] + endCalls: number + emitCloseOnEnd: boolean + pendingRealpathCallbacks: RealpathCallback[] + pendingLstatCallbacks: LstatCallback[] + realpath: ReturnType + lstat: ReturnType + mkdir: ReturnType + createWriteStream: ReturnType + end: ReturnType +} + +type RealpathCallback = (err: Error | null, resolved?: string) => void +type LstatCallback = (err: Error | null) => void + +function createFakeSftp(options?: { pendingRealpath?: boolean; pendingLstat?: boolean }): FakeSftp { + const sftp = new EventEmitter() as FakeSftp + sftp.realpathCalls = [] + sftp.lstatCalls = [] + sftp.writtenPaths = [] + sftp.mkdirPaths = [] + sftp.endCalls = 0 + sftp.emitCloseOnEnd = true + sftp.pendingRealpathCallbacks = [] + sftp.pendingLstatCallbacks = [] + sftp.realpath = vi.fn((path: string, cb: RealpathCallback) => { + sftp.realpathCalls.push(path) + if (options?.pendingRealpath) { + sftp.pendingRealpathCallbacks.push(cb) + return + } + cb(null, SFTP_HOME) + }) + // The install-owner marker exists only under the SFTP start directory. + sftp.lstat = vi.fn((path: string, cb: LstatCallback) => { + sftp.lstatCalls.push(path) + if (options?.pendingLstat) { + sftp.pendingLstatCallbacks.push(cb) + return + } + if (path === `${SFTP_HOME}/${RELAY_DIR}/${MARKER}`) { + cb(null) + return + } + cb(Object.assign(new Error('No such file'), { code: 2 })) + }) + sftp.mkdir = vi.fn((path: string, cb: (err: Error | null) => void) => { + sftp.mkdirPaths.push(path) + cb(null) + }) + sftp.createWriteStream = vi.fn((path: string) => { + sftp.writtenPaths.push(path) + const ws = new EventEmitter() + return Object.assign(ws, { + end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)), + destroy: vi.fn(), + off: ws.removeListener.bind(ws), + write: vi.fn(), + on: ws.on.bind(ws) + }) + }) + sftp.end = vi.fn(() => { + sftp.endCalls += 1 + if (sftp.emitCloseOnEnd) { + setTimeout(() => sftp.emit('close'), 0) + } + }) + return sftp +} + +function createTarget(): SshTarget { + return { + id: 'target-1', + label: 'Synology', + host: 'nas.local', + port: 22, + username: 'alice', + authMethod: 'agent' + } as SshTarget +} + +function connectedTo( + sftpSessions: FakeSftp[], + options?: { sftpError?: Error; useSystemSsh?: boolean } +): SshConnection { + const conn = new SshConnection(createTarget(), { + onStateChange: vi.fn(), + onLog: vi.fn() + } as never) + let handed = 0 + const client = { + sftp: (cb: (err: Error | undefined, sftp: unknown) => void) => { + if (options?.sftpError) { + cb(options.sftpError, undefined) + return + } + cb(undefined, sftpSessions[handed++] ?? sftpSessions.at(-1)) + } + } + // Why: the transfer branches are the unit under test; skip the connect handshake. + Object.assign(conn as unknown as Record, { + client, + useSystemSshTransport: options?.useSystemSsh ?? false + }) + return conn +} + +describe('SshConnection SFTP namespace resolution', () => { + const linux = getRemoteHostPlatform('linux-x64') + const windows = getRemoteHostPlatform('win32-x64') + let localDir: string + + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + // realpath: macOS /var is a symlink and uploadDirectory rejects a root that resolves elsewhere. + localDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-relay-'))) + writeFileSync(join(localDir, 'relay.js'), 'console.log(1)') + }) + + afterEach(() => { + rmSync(localDir, { recursive: true, force: true }) + }) + + it('writes to the SFTP namespace path discovered on the same session', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp]) + + await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, '0.1.0+hash', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version') + }) + + expect(sftp.realpathCalls).toEqual(['.']) + expect(sftp.writtenPaths).toEqual([`${SFTP_HOME}/${RELAY_DIR}/.version`]) + expect(sftp.endCalls).toBe(1) + }) + + it('uploads the bundle into the SFTP namespace directory', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp]) + + await conn.uploadDirectory(localDir, SHELL_RELAY_DIR, { + hostPlatform: linux, + sftpNamespace: namespace + }) + + expect(sftp.writtenPaths).toEqual([`${SFTP_HOME}/${RELAY_DIR}/relay.js`]) + expect(sftp.endCalls).toBe(1) + }) + + it('issues no discovery requests when the caller supplies no mapping', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp]) + + await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { hostPlatform: linux }) + + expect(sftp.realpathCalls).toEqual([]) + expect(sftp.lstatCalls).toEqual([]) + expect(sftp.writtenPaths).toEqual([`${SHELL_RELAY_DIR}/.version`]) + }) + + it('ignores a mapping on a Windows host', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp]) + + await conn.writeFile('C:\\Users\\alice\\relay\\.version', 'v', { + hostPlatform: windows, + sftpNamespace: fileNamespace('.version') + }) + + expect(sftp.realpathCalls).toEqual([]) + expect(sftp.writtenPaths).toEqual(['C:\\Users\\alice\\relay\\.version']) + }) + + it('never resolves on the system-SSH transport', async () => { + const conn = connectedTo([], { useSystemSsh: true }) + + await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version') + }) + await conn.uploadDirectory(localDir, SHELL_RELAY_DIR, { + hostPlatform: linux, + sftpNamespace: namespace + }) + + expect(vi.mocked(writeFileViaSystemSsh)).toHaveBeenCalledWith( + expect.anything(), + `${SHELL_RELAY_DIR}/.version`, + 'v', + expect.anything() + ) + expect(vi.mocked(uploadDirectoryViaSystemSsh)).toHaveBeenCalledWith( + expect.anything(), + localDir, + SHELL_RELAY_DIR, + expect.anything() + ) + }) + + // Why: only the relay-install writes carry a marker; other transfers have no owner to verify. + it('leaves writeBuffer and openFileUploadSession on the shell path', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp, sftp]) + + await conn.writeBuffer(`${SHELL_RELAY_DIR}/blob.bin`, Buffer.from('x'), { + hostPlatform: linux, + sftpNamespace: fileNamespace('blob.bin') + }) + const session = await conn.openFileUploadSession({ + hostPlatform: linux, + sftpNamespace: namespace + }) + session.close() + + expect(sftp.realpathCalls).toEqual([]) + expect(sftp.writtenPaths).toEqual([`${SHELL_RELAY_DIR}/blob.bin`]) + }) + + it('keeps the shell path when discovery is inconclusive', async () => { + const sftp = createFakeSftp() + sftp.lstat = vi.fn((path: string, cb: (err: Error | null) => void) => { + sftp.lstatCalls.push(path) + cb(Object.assign(new Error('permission denied'), { code: 3 })) + }) + const conn = connectedTo([sftp]) + + await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version') + }) + + expect(sftp.writtenPaths).toEqual([`${SHELL_RELAY_DIR}/.version`]) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('retaining shell path')) + }) + + it('aborts a transfer stuck in discovery and reports a confirmed channel close', async () => { + const sftp = createFakeSftp({ pendingRealpath: true }) + const conn = connectedTo([sftp]) + const controller = new AbortController() + + const write = conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1)) + controller.abort() + + const error = (await write.catch((err: Error) => err)) as Error & { + sshChannelCloseConfirmed?: boolean + } + expect(error.name).toBe('AbortError') + expect(error.sshChannelCloseConfirmed).toBe(true) + expect(sftp.endCalls).toBe(1) + expect(sftp.writtenPaths).toEqual([]) + }) + + it('reports an unconfirmed close when the aborted session never closes', async () => { + vi.useFakeTimers() + try { + const sftp = createFakeSftp({ pendingRealpath: true }) + sftp.emitCloseOnEnd = false + const conn = connectedTo([sftp]) + const controller = new AbortController() + + const write = conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + .catch((err: Error) => err) + await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1)) + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) + + const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean } + expect(error.name).toBe('AbortError') + expect(error.sshChannelCloseConfirmed).toBe(false) + expect(sftp.endCalls).toBe(1) + } finally { + vi.useRealTimers() + } + }) + + // Why: relay deploy classifies MaxSessions to back off; wrapping it would hide the retry signal. + it('preserves a session-limit channel-open error unchanged', async () => { + const original = Object.assign( + new Error('Channel open failure: open failed reason 4: MaxSessions'), + { reason: 4 } + ) + const sftp = createFakeSftp() + const conn = connectedTo([sftp], { + sftpError: original + }) + + const error = await conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version') + }) + .catch((err: Error) => err) + + expect(error).toBe(original) + expect(isSshSessionLimitError(error)).toBe(true) + expect(sftp.realpathCalls).toEqual([]) + expect(sftp.writtenPaths).toEqual([]) + }) + + it.each([ + ['succeeds', (callback: RealpathCallback) => callback(null, SFTP_HOME)], + ['rejects', (callback: RealpathCallback) => callback(new Error('late REALPATH failure'))] + ])( + 'ignores a late REALPATH callback that %s after a confirmed abort', + async (_outcome, completeRealpath) => { + const sftp = createFakeSftp({ pendingRealpath: true }) + const conn = connectedTo([sftp]) + const controller = new AbortController() + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + try { + const write = conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + .catch((err: Error) => err) + await vi.waitFor(() => expect(sftp.pendingRealpathCallbacks).toHaveLength(1)) + controller.abort() + + const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean } + expect(error).toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: true + }) + + completeRealpath(sftp.pendingRealpathCallbacks[0]!) + await new Promise((resolve) => setImmediate(resolve)) + + expect(unhandledRejection).not.toHaveBeenCalled() + expect(sftp.writtenPaths).toEqual([]) + expect(sftp.endCalls).toBe(1) + } finally { + process.off('unhandledRejection', unhandledRejection) + } + } + ) + + it.each([ + ['succeeds', (callback: LstatCallback) => callback(null)], + [ + 'rejects', + (callback: LstatCallback) => + callback(Object.assign(new Error('late LSTAT failure'), { code: 3 })) + ] + ])( + 'ignores a late LSTAT callback that %s after an unconfirmed abort', + async (_outcome, completeLstat) => { + vi.useFakeTimers() + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + try { + const sftp = createFakeSftp({ pendingLstat: true }) + sftp.emitCloseOnEnd = false + const conn = connectedTo([sftp]) + const controller = new AbortController() + const write = conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + .catch((err: Error) => err) + await vi.waitFor(() => expect(sftp.pendingLstatCallbacks).toHaveLength(1)) + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) + + const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean } + expect(error).toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + + completeLstat(sftp.pendingLstatCallbacks[0]!) + await Promise.resolve() + await Promise.resolve() + + expect(unhandledRejection).not.toHaveBeenCalled() + expect(sftp.writtenPaths).toEqual([]) + expect(sftp.endCalls).toBe(1) + } finally { + process.off('unhandledRejection', unhandledRejection) + vi.useRealTimers() + } + } + ) + + it('swallows a late session error after the transfer settled', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp]) + + await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version') + }) + + expect(() => sftp.emit('error', new Error('late channel reset'))).not.toThrow() + }) +}) diff --git a/src/main/ssh/ssh-connection-sftp-wire.test.ts b/src/main/ssh/ssh-connection-sftp-wire.test.ts new file mode 100644 index 00000000000..9f99dc22233 --- /dev/null +++ b/src/main/ssh/ssh-connection-sftp-wire.test.ts @@ -0,0 +1,361 @@ +import { lstat, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, posix } from 'node:path' +import { Client, Server as Ssh2Server, utils, type Connection, type SFTPWrapper } from 'ssh2' +import { expect, it, vi } from 'vitest' +import type { SshTarget } from '../../shared/ssh-types' +import { SshConnection } from './ssh-connection' +import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const SHELL_HOME = '/var/services/homes/alice' +const SFTP_HOME = '/homes/alice' +const RELAY_DIR = '.orca-remote/relay-0.1.0+wire' +const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_DIR}` +const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_DIR}` +const MARKER_FILE = `.sftp-namespace-${'a'.repeat(32)}` +const MARKER_PATH = `.install-lock/${MARKER_FILE}` +const SFTP_OPEN_WRITE = 2 +const SFTP_STATUS_OK = 0 +const SFTP_STATUS_NO_SUCH_FILE = 2 +const SFTP_STATUS_FAILURE = 4 + +type SftpWireServer = { + port: number + operations: string[] + close: () => Promise +} + +type OpenFile = Awaited> + +function backingPath(backingRoot: string, remotePath: string): string | null { + if (remotePath === SFTP_HOME) { + return backingRoot + } + if (!remotePath.startsWith(`${SFTP_HOME}/`)) { + return null + } + const relativePath = posix.relative(SFTP_HOME, remotePath) + if (!relativePath || relativePath.startsWith('../') || posix.isAbsolute(relativePath)) { + return null + } + return join(backingRoot, ...relativePath.split('/')) +} + +function sendFsError(sftp: SFTPWrapper, requestId: number, error: unknown): void { + const code = + error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ? SFTP_STATUS_NO_SUCH_FILE + : SFTP_STATUS_FAILURE + sftp.status(requestId, code) +} + +function fileId(handle: Buffer, files: Map): number | null { + if (handle.length !== 4) { + return null + } + const id = handle.readUInt32BE(0) + return files.has(id) ? id : null +} + +function installSftpHandlers(sftp: SFTPWrapper, backingRoot: string, operations: string[]): void { + const files = new Map() + let nextFileId = 0 + sftp.on('error', () => {}) + sftp.on('REALPATH', (requestId, remotePath) => { + operations.push(`REALPATH:${remotePath}`) + if (remotePath !== '.') { + sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE) + return + } + sftp.name(requestId, [ + { + filename: SFTP_HOME, + longname: SFTP_HOME, + attrs: { mode: 0o40_755, uid: 0, gid: 0, size: 0, atime: 0, mtime: 0 } + } + ]) + }) + sftp.on('LSTAT', (requestId, remotePath) => { + operations.push(`LSTAT:${remotePath}`) + const localPath = backingPath(backingRoot, remotePath) + if (!localPath) { + sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE) + return + } + void lstat(localPath).then( + (stats) => + sftp.attrs(requestId, { + mode: stats.mode, + size: stats.size, + uid: stats.uid, + gid: stats.gid, + atime: Math.floor(stats.atimeMs / 1000), + mtime: Math.floor(stats.mtimeMs / 1000) + }), + (error: unknown) => sendFsError(sftp, requestId, error) + ) + }) + sftp.on('MKDIR', (requestId, remotePath) => { + operations.push(`MKDIR:${remotePath}`) + const localPath = backingPath(backingRoot, remotePath) + if (!localPath) { + sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE) + return + } + void mkdir(localPath).then( + () => sftp.status(requestId, SFTP_STATUS_OK), + (error: unknown) => sendFsError(sftp, requestId, error) + ) + }) + sftp.on('OPEN', (requestId, remotePath, flags) => { + operations.push(`OPEN:${remotePath}`) + const localPath = backingPath(backingRoot, remotePath) + if (!localPath || !(flags & SFTP_OPEN_WRITE)) { + sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE) + return + } + void open(localPath, 'w').then( + (file) => { + const id = nextFileId++ + files.set(id, file) + const handle = Buffer.alloc(4) + handle.writeUInt32BE(id) + sftp.handle(requestId, handle) + }, + (error: unknown) => sendFsError(sftp, requestId, error) + ) + }) + sftp.on('WRITE', (requestId, handle, offset, data) => { + operations.push('WRITE') + const id = fileId(handle, files) + if (id === null) { + sftp.status(requestId, SFTP_STATUS_FAILURE) + return + } + void files + .get(id)! + .write(data, 0, data.length, offset) + .then( + () => sftp.status(requestId, SFTP_STATUS_OK), + (error: unknown) => sendFsError(sftp, requestId, error) + ) + }) + sftp.on('CLOSE', (requestId, handle) => { + operations.push('CLOSE') + const id = fileId(handle, files) + if (id === null) { + sftp.status(requestId, SFTP_STATUS_FAILURE) + return + } + const file = files.get(id)! + files.delete(id) + void file.close().then( + () => sftp.status(requestId, SFTP_STATUS_OK), + (error: unknown) => sendFsError(sftp, requestId, error) + ) + }) +} + +async function startSftpWireServer(backingRoot: string): Promise { + const operations: string[] = [] + const connections = new Set() + const hostKey = utils.generateKeyPairSync('ed25519').private + const server = new Ssh2Server({ hostKeys: [hostKey] }, (connection) => { + connections.add(connection) + connection.on('error', () => {}) + connection.on('close', () => connections.delete(connection)) + connection.on('authentication', (context) => { + if ( + context.method === 'password' && + context.username === 'fixture' && + context.password === 'secret' + ) { + context.accept() + } else { + context.reject() + } + }) + connection.on('ready', () => { + connection.on('session', (accept) => { + const session = accept() + session.on('sftp', (acceptSftp) => { + installSftpHandlers(acceptSftp(), backingRoot, operations) + }) + }) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject) + resolve() + }) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('SSH fixture did not bind a TCP port') + } + return { + port: address.port, + operations, + close: async () => { + for (const connection of connections) { + connection.end() + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + } +} + +function connectSshClient(port: number): Promise { + return new Promise((resolve, reject) => { + const client = new Client() + client.once('ready', () => resolve(client)) + client.once('error', reject) + client.connect({ + host: '127.0.0.1', + port, + username: 'fixture', + password: 'secret', + hostVerifier: () => true, + readyTimeout: 5_000 + }) + }) +} + +function connectionWithClient(client: Client): SshConnection { + const target = { + id: 'sftp-wire', + label: 'SFTP wire fixture', + host: '127.0.0.1', + port: 22, + username: 'fixture', + authMethod: 'password' + } as SshTarget + const connection = new SshConnection(target, { onStateChange: vi.fn() }) + Object.assign(connection as unknown as Record, { + client, + useSystemSshTransport: false + }) + return connection +} + +async function boundedTransfer( + operation: Promise, + operations: string[], + label: string +): Promise { + let timeout: ReturnType | undefined + try { + await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`SFTP wire ${label} timed out: ${operations.join(', ')}`)), + 5_000 + ) + }) + ]) + } finally { + clearTimeout(timeout) + } +} + +function splitNamespaceMapping(homeRelativePath: string): SftpNamespacePathMapping { + return { + homeRelativePath, + shellProbePath: `${SHELL_RELAY_DIR}/${MARKER_PATH}`, + homeRelativeProbePath: `${RELAY_DIR}/${MARKER_PATH}` + } +} + +async function withSplitNamespaceFixture( + run: (args: { + connection: SshConnection + fixture: SftpWireServer + backingRelayDir: string + }) => Promise +): Promise { + const backingRoot = await mkdtemp(join(tmpdir(), 'orca-sftp-wire-remote-')) + const backingRelayDir = join(backingRoot, ...RELAY_DIR.split('/')) + await mkdir(join(backingRelayDir, '.install-lock'), { recursive: true }) + await writeFile(join(backingRelayDir, '.install-lock', MARKER_FILE), '') + + let fixture: SftpWireServer | undefined + let client: Client | undefined + try { + fixture = await startSftpWireServer(backingRoot) + client = await connectSshClient(fixture.port) + await run({ + connection: connectionWithClient(client), + fixture, + backingRelayDir + }) + } finally { + client?.end() + await fixture?.close() + await rm(backingRoot, { recursive: true, force: true }) + } +} + +it('uploads through a verified split namespace over a real ssh2 SFTP session', async () => { + const localDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-sftp-wire-local-'))) + await mkdir(join(localDir, 'nested')) + await writeFile(join(localDir, 'nested', 'payload.bin'), Buffer.from([0, 1, 2, 255])) + + try { + await withSplitNamespaceFixture(async ({ connection, fixture, backingRelayDir }) => { + const mapping = splitNamespaceMapping(RELAY_DIR) + + await boundedTransfer( + connection.uploadDirectory(localDir, SHELL_RELAY_DIR, { + hostPlatform: getRemoteHostPlatform('linux-x64'), + sftpNamespace: mapping + }), + fixture.operations, + 'upload' + ) + + expect(await readFile(join(backingRelayDir, 'nested', 'payload.bin'))).toEqual( + Buffer.from([0, 1, 2, 255]) + ) + expect(fixture.operations).toContain(`REALPATH:.`) + expect(fixture.operations).toContain(`LSTAT:${mapping.shellProbePath}`) + expect(fixture.operations).toContain(`LSTAT:${SFTP_RELAY_DIR}/${MARKER_PATH}`) + expect(fixture.operations).toContain(`MKDIR:${SFTP_RELAY_DIR}/nested`) + expect(fixture.operations).toContain(`OPEN:${SFTP_RELAY_DIR}/nested/payload.bin`) + expect(fixture.operations).toEqual(expect.arrayContaining(['WRITE', 'CLOSE'])) + }) + } finally { + await rm(localDir, { recursive: true, force: true }) + } +}, 15_000) + +it('writes a mapped file through a verified split namespace over a real ssh2 SFTP session', async () => { + await withSplitNamespaceFixture(async ({ connection, fixture, backingRelayDir }) => { + const mapping = splitNamespaceMapping(`${RELAY_DIR}/package.json`) + const shellFilePath = `${SHELL_RELAY_DIR}/package.json` + const contents = '{"name":"orca-relay"}\n' + + await boundedTransfer( + connection.writeFile(shellFilePath, contents, { + hostPlatform: getRemoteHostPlatform('linux-x64'), + sftpNamespace: mapping + }), + fixture.operations, + 'writeFile' + ) + + expect(await readFile(join(backingRelayDir, 'package.json'), 'utf8')).toBe(contents) + expect(fixture.operations).toContain(`REALPATH:.`) + expect(fixture.operations).toContain(`LSTAT:${mapping.shellProbePath}`) + expect(fixture.operations).toContain(`LSTAT:${SFTP_RELAY_DIR}/${MARKER_PATH}`) + expect(fixture.operations).toContain(`OPEN:${SFTP_RELAY_DIR}/package.json`) + expect(fixture.operations).toEqual(expect.arrayContaining(['WRITE', 'CLOSE'])) + // Shell-namespace path must never be opened for a confirmed split write. + expect(fixture.operations).not.toContain(`OPEN:${shellFilePath}`) + }) +}, 15_000) diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 50f2eb66ff2..8ff44e65787 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -24,8 +24,11 @@ function testHomePath(...parts: string[]): string { } vi.mock('fs', () => ({ - existsSync: (...args: unknown[]) => mockExistsSync(...args), - readFileSync: (...args: unknown[]) => mockReadFileSync(...args) + existsSync: (...args: unknown[]) => mockExistsSync(...args) +})) + +vi.mock('./ssh-key-file', () => ({ + readSshKeyFile: (...args: unknown[]) => mockReadFileSync(...args) })) import { @@ -51,7 +54,10 @@ import type { SshResolvedConfig } from './ssh-config-parser' type MockProxyProcess = EventEmitter & { stdin: EventEmitter & { write: ReturnType } - stdout: EventEmitter + stdout: EventEmitter & { + pause: ReturnType + resume: ReturnType + } stderr: EventEmitter } @@ -60,7 +66,10 @@ function createMockProxyProcess(): MockProxyProcess { proc.stdin = Object.assign(new EventEmitter(), { write: vi.fn((_chunk, cb?: (error?: Error | null) => void) => cb?.()) }) - proc.stdout = new EventEmitter() + proc.stdout = Object.assign(new EventEmitter(), { + pause: vi.fn(), + resume: vi.fn() + }) proc.stderr = new EventEmitter() return proc } @@ -723,4 +732,23 @@ describe('spawnProxyCommand', () => { expect(proc.stdin.listenerCount('error')).toBe(0) expect(proc.listenerCount('error')).toBe(0) }) + + it('pauses proxy stdout at the socket high-water mark and resumes on demand', async () => { + const proc = createMockProxyProcess() + spawnMock.mockReturnValue(proc) + const { sock } = spawnProxyCommand( + { kind: 'jump-host', jumpHost: 'bastion.example.com' }, + 'target.example.com', + 22, + 'deploy' + ) + + proc.stdout.emit('data', Buffer.alloc(64 * 1024)) + + expect(proc.stdout.pause).toHaveBeenCalledOnce() + sock.on('data', () => {}) + await new Promise((resolve) => setImmediate(resolve)) + expect(proc.stdout.resume).toHaveBeenCalled() + sock.destroy() + }) }) diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index de9e0b6c63f..d51cda25bbb 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -311,7 +311,9 @@ export function spawnProxyCommand( proc.off('error', onProcessError) } const onStdoutData = (data: Buffer): void => { - stream.push(data) + if (!stream.push(data)) { + proc.stdout!.pause() + } } const onStdoutEnd = (): void => { stream.push(null) @@ -323,7 +325,9 @@ export function spawnProxyCommand( stream.destroy(err) } const stream = new Duplex({ - read() {}, + read() { + proc.stdout!.resume() + }, write(chunk, _encoding, cb) { proc.stdin!.write(chunk, cb) }, diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 1ab923bee60..77df52a0677 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -168,6 +168,7 @@ import { } from './ssh-system-fallback' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' +import { SSH_CONNECTION_ERROR_MAX_UTF8_BYTES } from '../../shared/ssh-retained-payload-admission' function createTarget(overrides?: Partial): SshTarget { return { @@ -436,6 +437,16 @@ describe('SshConnection', () => { expect(conn.getState().status).toBe('error') }) + it('does not retain an oversized provider error in connection state', async () => { + connectBehavior = 'error' + connectErrorMessage = 'x'.repeat(SSH_CONNECTION_ERROR_MAX_UTF8_BYTES + 100) + const conn = new SshConnection(createTarget(), createCallbacks()) + + await conn.connect().catch(() => undefined) + + expect(conn.getState().error).toHaveLength(SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) + }) + it('guards late ssh2 errors emitted while destroying a failed startup client', async () => { connectBehavior = 'error' connectErrorMessage = 'Connection lost before handshake' @@ -1099,228 +1110,6 @@ describe('SshConnection', () => { expect(conn.canRunConcurrentExecCommands()).toBe(false) }) - it('accepts GitHub restricted-shell SSH probes with resolved user fallback', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce( - createResolvedConfig({ hostname: 'github.com', user: 'git' }) - ) - spawnSystemSshCommandMock.mockImplementation(() => - createFailingSystemCommandChannel(1, 'Invalid command: echo ORCA-SYSTEM-SSH-OK') - ) - const conn = new SshConnection( - createTarget({ - configHost: 'github.com', - host: 'github.com', - username: undefined - }), - createCallbacks() - ) - - await conn.connect() - - expect(conn.getState().status).toBe('connected') - expect(conn.usesSystemSshTransport()).toBe(true) - }) - - it('accepts GitHub restricted-shell SSH probes with resolved host and target username', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce( - createResolvedConfig({ hostname: 'github.com', user: undefined }) - ) - spawnSystemSshCommandMock.mockImplementation(() => - createFailingSystemCommandChannel(1, 'Invalid command: echo ORCA-SYSTEM-SSH-OK') - ) - const conn = new SshConnection( - createTarget({ - configHost: 'github.com', - host: 'github.com', - username: 'git' - }), - createCallbacks() - ) - - await conn.connect() - - expect(conn.getState().status).toBe('connected') - expect(conn.usesSystemSshTransport()).toBe(true) - }) - - it('accepts ssh.github.com restricted-shell SSH probes', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce( - createResolvedConfig({ hostname: 'ssh.github.com', user: 'git' }) - ) - spawnSystemSshCommandMock.mockImplementation(() => - createFailingSystemCommandChannel(1, 'Invalid command: echo ORCA-SYSTEM-SSH-OK') - ) - const conn = new SshConnection( - createTarget({ - configHost: 'ssh.github.com', - host: 'ssh.github.com', - username: 'git' - }), - createCallbacks() - ) - - await conn.connect() - - expect(conn.getState().status).toBe('connected') - expect(conn.usesSystemSshTransport()).toBe(true) - }) - - it('accepts GitHub restricted-shell SSH probes with the real git:// advisory transcript', async () => { - // Real 4-line stderr GitHub returns for an invalid command (issue #6988). - vi.mocked(resolveWithSshG).mockResolvedValueOnce( - createResolvedConfig({ hostname: 'github.com', user: 'git' }) - ) - spawnSystemSshCommandMock.mockImplementation(() => - createFailingSystemCommandChannel( - 1, - 'Invalid command: echo ORCA-SYSTEM-SSH-OK\n' + - ' You appear to be using ssh to clone a git:// URL.\n' + - ' Make sure your core.gitProxy config option and the\n' + - ' GIT_PROXY_COMMAND environment variable are NOT set.' - ) - ) - const conn = new SshConnection( - createTarget({ - configHost: 'github.com', - host: 'github.com', - username: 'git' - }), - createCallbacks() - ) - - await conn.connect() - - expect(conn.getState().status).toBe('connected') - expect(conn.usesSystemSshTransport()).toBe(true) - }) - - it('accepts GitHub restricted-shell SSH probes when OpenSSH config resolution fails', async () => { - vi.stubEnv('ORCA_SSH_FORCE_SYSTEM_TRANSPORT', '1') - vi.mocked(resolveWithSshG).mockRejectedValueOnce(new Error('ssh -G failed')) - spawnSystemSshCommandMock.mockImplementation(() => - createFailingSystemCommandChannel(1, 'Invalid command: echo ORCA-SYSTEM-SSH-OK') - ) - const conn = new SshConnection( - createTarget({ - configHost: 'github.com', - host: 'github.com', - username: 'git' - }), - createCallbacks() - ) - - await conn.connect() - - expect(conn.getState().status).toBe('connected') - expect(conn.usesSystemSshTransport()).toBe(true) - expect(conn.getSystemSshResolvedConfig()).toBeNull() - }) - - it('rejects non-GitHub SSH probes with GitHub invalid-command text', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce( - createResolvedConfig({ hostname: 'gitlab.com', user: 'git' }) - ) - spawnSystemSshCommandMock.mockImplementation(() => - createFailingSystemCommandChannel(1, 'Invalid command: echo ORCA-SYSTEM-SSH-OK') - ) - const conn = new SshConnection( - createTarget({ - configHost: 'github.com', - host: 'github.com', - username: 'git' - }), - createCallbacks() - ) - - await expect(conn.connect()).rejects.toThrow('System SSH probe failed (exit 1)') - expect(conn.usesSystemSshTransport()).toBe(false) - }) - - it('accepts GitHub restricted-shell SSH probes when target username overrides resolved user', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce( - createResolvedConfig({ hostname: 'github.com', user: 'deploy' }) - ) - spawnSystemSshCommandMock.mockImplementation(() => - createFailingSystemCommandChannel(1, 'Invalid command: echo ORCA-SYSTEM-SSH-OK') - ) - const conn = new SshConnection( - createTarget({ - configHost: 'github.com', - host: 'github.com', - username: 'git' - }), - createCallbacks() - ) - - await conn.connect() - - expect(conn.getState().status).toBe('connected') - expect(conn.usesSystemSshTransport()).toBe(true) - }) - - it('rejects GitHub restricted-shell SSH probes when target username overrides resolved git user', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce( - createResolvedConfig({ hostname: 'github.com', user: 'git' }) - ) - spawnSystemSshCommandMock.mockImplementation(() => - createFailingSystemCommandChannel(1, 'Invalid command: echo ORCA-SYSTEM-SSH-OK') - ) - const conn = new SshConnection( - createTarget({ - configHost: 'github.com', - host: 'github.com', - username: 'deploy' - }), - createCallbacks() - ) - - await expect(conn.connect()).rejects.toThrow('System SSH probe failed (exit 1)') - expect(conn.usesSystemSshTransport()).toBe(false) - }) - - it('rejects GitHub restricted-shell SSH probes with extra stderr text', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce( - createResolvedConfig({ hostname: 'github.com', user: 'git' }) - ) - spawnSystemSshCommandMock.mockImplementation(() => - createFailingSystemCommandChannel( - 1, - 'remote: rejected\nInvalid command: echo ORCA-SYSTEM-SSH-OK\ntry again' - ) - ) - const conn = new SshConnection( - createTarget({ - configHost: 'github.com', - host: 'github.com', - username: 'git' - }), - createCallbacks() - ) - - await expect(conn.connect()).rejects.toThrow('System SSH probe failed (exit 1)') - expect(conn.usesSystemSshTransport()).toBe(false) - }) - - it('rejects GitHub restricted-shell SSH probes for non-git users', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce( - createResolvedConfig({ hostname: 'github.com', user: 'deploy' }) - ) - spawnSystemSshCommandMock.mockImplementation(() => - createFailingSystemCommandChannel(1, 'Invalid command: echo ORCA-SYSTEM-SSH-OK') - ) - const conn = new SshConnection( - createTarget({ - configHost: 'github.com', - host: 'github.com', - username: 'deploy' - }), - createCallbacks() - ) - - await expect(conn.connect()).rejects.toThrow('System SSH probe failed (exit 1)') - expect(conn.usesSystemSshTransport()).toBe(false) - }) - it('retries a failed system SSH probe without ControlMaster and disables mux for the session', async () => { getOrcaControlSocketPathMock.mockImplementation( (_target: SshTarget, options?: { disableControlMaster?: boolean }) => diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index b16cbce866d..e65e3da720e 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -5,6 +5,7 @@ import { Client as SshClient } from 'ssh2' import type { ChildProcess } from 'node:child_process' import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2' import type { SshTarget, SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types' +import { clampSshConnectionError } from '../../shared/ssh-retained-payload-admission' import { getOrcaControlSocketPath, spawnSystemSsh, @@ -40,16 +41,23 @@ import { type SshConnectionCallbacks } from './ssh-connection-utils' import type { RemoteHostPlatform } from './ssh-remote-platform' +import { + resolveSftpTransferPathIfMapped, + type SftpNamespacePathMapping +} from './sftp-namespace-resolution' import type { FileUploadSession } from '../providers/types' import { isSshSessionLimitError } from './ssh-session-limit-error' import { createLinkedSshFileTransferSignal, raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort' +import { SystemSshOutputTail } from './system-ssh-output-tail' export type { SshConnectionCallbacks } from './ssh-connection-utils' type SshRemoteFileOptions = { hostPlatform?: RemoteHostPlatform + // Only uploadDirectory and writeFile honor this, and only on the non-Windows ssh2 branch. + sftpNamespace?: SftpNamespacePathMapping } // Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. @@ -66,36 +74,6 @@ function cloneResolvedConfig(config: SshResolvedConfig | null): SshResolvedConfi return { ...config, identityFile: [...config.identityFile] } } -function isGitHubRestrictedShellProbeSuccess( - target: SshTarget, - resolvedConfig: SshResolvedConfig | null, - code: number | null, - stderr: string -): boolean { - if (code !== 1) { - return false - } - - const effectiveUser = (target.username?.trim() || resolvedConfig?.user?.trim())?.toLowerCase() - if (effectiveUser !== 'git') { - return false - } - - // GitHub appends git:// advisory lines after the invalid-command line (issue #6988), so match the first line only. - const firstLine = stderr.split('\n', 1)[0]?.trim() - if (firstLine !== 'Invalid command: echo ORCA-SYSTEM-SSH-OK') { - return false - } - - const resolvedHost = resolvedConfig?.hostname?.trim() - const hostCandidates = resolvedHost ? [resolvedHost] : [target.host, target.configHost] - - return hostCandidates.some((host) => { - const normalizedHost = host?.trim().toLowerCase() - return normalizedHost === 'github.com' || normalizedHost === 'ssh.github.com' - }) -} - export class SshConnection { private client: SshClient | null = null private proxyProcess: ChildProcess | null = null @@ -423,15 +401,17 @@ export class SshConnection { sftp.on('error', swallowLateSftpError) sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) try { - const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') - await raceSftpFileTransferWithAbort( - uploadDirectory(sftp, localDir, remoteDir), - linkedSignal.signal, - (onClose) => { - sftp.once('close', onClose) - endSftp() - } - ) + // Why: resolve on the same session that transfers — a later session is not authoritative for this one's namespace. + const transfer = (async (): Promise => { + const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options) + linkedSignal.signal.throwIfAborted() + const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') + await uploadDirectory(sftp, localDir, targetDir) + })() + await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + }) } finally { endSftp() } @@ -519,35 +499,13 @@ export class SshConnection { sftp.on('error', swallowLateSftpError) sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) try { - const write = new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(remotePath) - let settled = false - const cleanup = (): void => { - sftp.removeListener('error', onError) - ws.removeListener('close', onClose) - ws.removeListener('error', onError) - } - const onClose = (): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve() - } - const onError = (err: Error): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(err) - } - sftp.prependOnceListener('error', onError) - ws.once('close', onClose) - ws.once('error', onError) - ws.end(contents) - }) + // Why: resolve on the same session that writes — a later session is not authoritative for this one's namespace. + const write = (async (): Promise => { + const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options) + linkedSignal.signal.throwIfAborted() + const { writeStringViaSftp } = await import('./sftp-upload') + await writeStringViaSftp(sftp, targetPath, contents) + })() await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { sftp.once('close', onClose) endSftp() @@ -843,8 +801,8 @@ export class SshConnection { }) try { await new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' + const stdout = new SystemSshOutputTail() + const stderr = new SystemSshOutputTail() let settled = false const cleanup = (): void => { clearTimeout(timeout) @@ -863,10 +821,10 @@ export class SshConnection { callback() } const onStdoutData = (data: Buffer): void => { - stdout += data.toString('utf-8') + stdout.push(data) } const onStderrData = (data: Buffer): void => { - stderr += data.toString('utf-8') + stderr.push(data) } const onError = (err: Error): void => { settle(() => reject(err)) @@ -877,24 +835,17 @@ export class SshConnection { reject(new Error('SSH connection attempt was cancelled')) return } - if ( - (code === 0 && stdout.includes('ORCA-SYSTEM-SSH-OK')) || - isGitHubRestrictedShellProbeSuccess( - this.target, - this.systemSshResolvedConfig, - code, - stderr + if (code !== 0 || !stdout.toString().includes('ORCA-SYSTEM-SSH-OK')) { + const stderrText = stderr.toString() + reject( + new Error( + `System SSH probe failed${code != null ? ` (exit ${code})` : ''}.${stderrText ? ` stderr: ${stderrText.trim()}` : ''}` + ) ) - ) { - this.setState('connected') - resolve() return } - reject( - new Error( - `System SSH probe failed${code != null ? ` (exit ${code})` : ''}.${stderr ? ` stderr: ${stderr.trim()}` : ''}` - ) - ) + this.setState('connected') + resolve() }) } const timeout = setTimeout(() => { @@ -1365,7 +1316,7 @@ export class SshConnection { this.state = { ...this.state, status, - error: error ?? null, + error: clampSshConnectionError(error ?? null), supportsFolderDownload: status === 'connected' && !this.useSystemSshTransport } this.callbacks.onStateChange(this.target.id, { ...this.state }) diff --git a/src/main/ssh/ssh-directory-transfer-budget.test.ts b/src/main/ssh/ssh-directory-transfer-budget.test.ts new file mode 100644 index 00000000000..0e2c94594f2 --- /dev/null +++ b/src/main/ssh/ssh-directory-transfer-budget.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + SshDirectoryTransferBudget, + type SshDirectoryTransferLimits +} from './ssh-directory-transfer-budget' + +const limits: SshDirectoryTransferLimits = { + maximumEntries: 2, + maximumDepth: 2, + maximumPathBytes: 4, + maximumRetainedPathBytes: 8, + maximumFileBytes: 4, + maximumTotalFileBytes: 8 +} + +describe('SshDirectoryTransferBudget', () => { + it('accepts every exact boundary', () => { + const budget = new SshDirectoryTransferBudget(limits) + + budget.recordPath('1234', 2) + budget.recordPath('5678', 2) + budget.recordFile(4) + budget.recordFile(4) + }) + + it.each([ + ['entries', (budget: SshDirectoryTransferBudget) => budget.recordPath('', 0)], + ['depth', (budget: SshDirectoryTransferBudget) => budget.recordPath('', 3)], + ['path', (budget: SshDirectoryTransferBudget) => budget.recordPath('12345', 0)], + [ + 'paths', + (budget: SshDirectoryTransferBudget) => budget.recordPath('1', 0, { countEntry: false }) + ], + ['file', (budget: SshDirectoryTransferBudget) => budget.recordFile(5)], + ['files', (budget: SshDirectoryTransferBudget) => budget.recordFile(1)] + ] as const)('rejects one unit beyond the %s boundary', (reason, exceed) => { + const budget = new SshDirectoryTransferBudget(limits) + if (reason === 'entries') { + budget.recordPath('', 0) + budget.recordPath('', 0) + } else if (reason === 'paths') { + budget.recordPath('1234', 0, { countEntry: false }) + budget.recordPath('5678', 0, { countEntry: false }) + } else if (reason === 'files') { + budget.recordFile(4) + budget.recordFile(4) + } + + expect(() => exceed(budget)).toThrow(expect.objectContaining({ reason })) + }) +}) diff --git a/src/main/ssh/ssh-directory-transfer-budget.ts b/src/main/ssh/ssh-directory-transfer-budget.ts new file mode 100644 index 00000000000..18a965b06d5 --- /dev/null +++ b/src/main/ssh/ssh-directory-transfer-budget.ts @@ -0,0 +1,64 @@ +export type SshDirectoryTransferLimits = { + maximumEntries: number + maximumDepth: number + maximumPathBytes: number + maximumRetainedPathBytes: number + maximumFileBytes: number + maximumTotalFileBytes: number +} + +export const SSH_DIRECTORY_TRANSFER_LIMITS: SshDirectoryTransferLimits = { + maximumEntries: 4_096, + maximumDepth: 64, + maximumPathBytes: 16 * 1024, + maximumRetainedPathBytes: 4 * 1024 * 1024, + maximumFileBytes: 16 * 1024 * 1024, + maximumTotalFileBytes: 32 * 1024 * 1024 +} + +export const WINDOWS_SSH_UPLOAD_PACKAGE_MAX_BYTES = 48 * 1024 * 1024 + +export class SshDirectoryTransferCapacityError extends Error { + constructor(readonly reason: 'entries' | 'depth' | 'path' | 'paths' | 'file' | 'files') { + super(`SSH directory transfer exceeds the ${reason} limit`) + this.name = 'SshDirectoryTransferCapacityError' + } +} + +export class SshDirectoryTransferBudget { + private entries = 0 + private retainedPathBytes = 0 + private totalFileBytes = 0 + + constructor(private readonly limits = SSH_DIRECTORY_TRANSFER_LIMITS) {} + + recordPath(path: string, depth: number, options?: { countEntry?: boolean }): void { + if (depth > this.limits.maximumDepth) { + throw new SshDirectoryTransferCapacityError('depth') + } + if (options?.countEntry !== false) { + this.entries += 1 + if (this.entries > this.limits.maximumEntries) { + throw new SshDirectoryTransferCapacityError('entries') + } + } + const pathBytes = Buffer.byteLength(path, 'utf8') + if (pathBytes > this.limits.maximumPathBytes) { + throw new SshDirectoryTransferCapacityError('path') + } + this.retainedPathBytes += pathBytes + if (this.retainedPathBytes > this.limits.maximumRetainedPathBytes) { + throw new SshDirectoryTransferCapacityError('paths') + } + } + + recordFile(fileBytes: number): void { + if (fileBytes > this.limits.maximumFileBytes) { + throw new SshDirectoryTransferCapacityError('file') + } + this.totalFileBytes += fileBytes + if (this.totalFileBytes > this.limits.maximumTotalFileBytes) { + throw new SshDirectoryTransferCapacityError('files') + } + } +} diff --git a/src/main/ssh/ssh-file-stream-assembler.ts b/src/main/ssh/ssh-file-stream-assembler.ts new file mode 100644 index 00000000000..4a40c6adf21 --- /dev/null +++ b/src/main/ssh/ssh-file-stream-assembler.ts @@ -0,0 +1,196 @@ +import type { FileReadResult } from '../providers/types' +import { RelayErrorCode, STREAM_CHUNK_SIZE } from './relay-protocol' +import { + defaultSshStreamAssemblyBudget, + type SshStreamAssemblyBudget, + base64EncodedLength +} from './ssh-stream-reader-memory' +import type { RasterImageDimensions } from '../../shared/raster-image-dimensions' +import { isRasterImagePreviewDimensions } from '../../shared/raster-image-preview-limits' + +const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 +const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 + +type FileStreamMetadata = { + streamId?: number + totalSize: number + isBinary: boolean + isImage?: boolean + mimeType?: string + imageDimensions?: RasterImageDimensions + resultEncoding?: 'base64' | 'utf-8' + empty?: boolean +} + +export class StreamProtocolError extends Error { + readonly code = RelayErrorCode.StreamProtocolError + constructor(message: string) { + super(message) + } +} + +export type FileStreamSetup = + | { kind: 'empty'; result: FileReadResult } + | { kind: 'stream'; streamId: number; assembler: FileStreamAssembler } + +export function createFileStreamSetup( + rawMetadata: unknown, + assemblyBudget: SshStreamAssemblyBudget = defaultSshStreamAssemblyBudget +): FileStreamSetup { + const metadata = rawMetadata as FileStreamMetadata + if ( + typeof metadata !== 'object' || + metadata === null || + typeof metadata.isBinary !== 'boolean' || + !Number.isSafeInteger(metadata.totalSize) || + (metadata.empty !== undefined && typeof metadata.empty !== 'boolean') || + (metadata.isImage !== undefined && typeof metadata.isImage !== 'boolean') || + (metadata.mimeType !== undefined && typeof metadata.mimeType !== 'string') || + (metadata.imageDimensions !== undefined && + !isRasterImagePreviewDimensions(metadata.imageDimensions)) || + (metadata.resultEncoding !== undefined && + metadata.resultEncoding !== 'base64' && + metadata.resultEncoding !== 'utf-8') + ) { + throw new StreamProtocolError('Malformed file stream metadata') + } + if (metadata.empty) { + return { + kind: 'empty', + result: { + content: '', + isBinary: metadata.isBinary, + ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), + ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}), + ...(metadata.imageDimensions !== undefined + ? { imageDimensions: metadata.imageDimensions } + : {}) + } + } + } + if ( + typeof metadata.streamId !== 'number' || + !Number.isSafeInteger(metadata.streamId) || + metadata.streamId <= 0 + ) { + throw new StreamProtocolError('Metadata missing streamId for non-empty stream') + } + const cap = metadata.isBinary ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (metadata.totalSize < 0 || metadata.totalSize > cap) { + throw new StreamProtocolError( + `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` + ) + } + return { + kind: 'stream', + streamId: metadata.streamId, + assembler: new FileStreamAssembler(metadata, assemblyBudget) + } +} + +export class FileStreamAssembler { + private buffer: Buffer | null + private expectedSeq = 0 + private bytesReceived = 0 + private readonly totalChunks: number + private releaseBudget: (() => void) | null + + constructor( + private readonly metadata: FileStreamMetadata, + assemblyBudget: SshStreamAssemblyBudget + ) { + this.releaseBudget = assemblyBudget.reserve(metadata.totalSize) + if (!this.releaseBudget) { + throw new StreamProtocolError( + `Active SSH stream assembly would exceed ${assemblyBudget.maxBytes} bytes` + ) + } + try { + this.buffer = Buffer.alloc(metadata.totalSize) + } catch (error) { + this.releaseMemory() + throw new StreamProtocolError( + `Failed to allocate ${metadata.totalSize} bytes: ${(error as Error).message}` + ) + } + this.totalChunks = + metadata.totalSize === 0 ? 0 : Math.ceil(metadata.totalSize / STREAM_CHUNK_SIZE) + } + + acceptChunk(params: Record, streamId: number): number { + const seq = params.seq + const data = params.data + if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || typeof data !== 'string') { + throw new StreamProtocolError(`Malformed chunk for stream ${streamId}`) + } + if (seq !== this.expectedSeq) { + throw new StreamProtocolError( + `Out-of-order chunk for stream ${streamId}: expected ${this.expectedSeq}, got ${seq}` + ) + } + if (seq >= this.totalChunks) { + throw new StreamProtocolError( + `File stream ${streamId} exceeded declared chunk count ${this.totalChunks}` + ) + } + const offset = seq * STREAM_CHUNK_SIZE + const expectedLength = Math.min(STREAM_CHUNK_SIZE, this.metadata.totalSize - offset) + if (expectedLength < 0 || data.length !== base64EncodedLength(expectedLength)) { + throw new StreamProtocolError( + `Encoded chunk length mismatch for stream ${streamId}: seq=${seq}` + ) + } + const decoded = Buffer.from(data, 'base64') + if (decoded.length !== expectedLength) { + throw new StreamProtocolError( + `Chunk length mismatch for stream ${streamId}: seq=${seq} expected=${expectedLength} got=${decoded.length}` + ) + } + if (!this.buffer) { + throw new StreamProtocolError(`Chunk arrived after completion for stream ${streamId}`) + } + decoded.copy(this.buffer, offset) + this.expectedSeq += 1 + this.bytesReceived += decoded.length + return seq + } + + finish(streamId: number): FileReadResult { + if (this.expectedSeq !== this.totalChunks || this.bytesReceived !== this.metadata.totalSize) { + throw new StreamProtocolError( + `File stream ${streamId} incomplete: chunks ${this.expectedSeq}/${this.totalChunks}, bytes ${this.bytesReceived}/${this.metadata.totalSize}` + ) + } + if (!this.buffer) { + throw new StreamProtocolError(`Stream ${streamId} already completed`) + } + const buffer = this.buffer + this.buffer = null + try { + return { + content: + (this.metadata.resultEncoding ?? 'base64') === 'base64' + ? buffer.toString('base64') + : buffer.toString('utf-8'), + isBinary: this.metadata.isBinary, + ...(this.metadata.isImage !== undefined ? { isImage: this.metadata.isImage } : {}), + ...(this.metadata.mimeType !== undefined ? { mimeType: this.metadata.mimeType } : {}), + ...(this.metadata.imageDimensions !== undefined + ? { imageDimensions: this.metadata.imageDimensions } + : {}) + } + } finally { + this.releaseMemory() + } + } + + release(): void { + this.buffer = null + this.releaseMemory() + } + + private releaseMemory(): void { + this.releaseBudget?.() + this.releaseBudget = null + } +} diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index 2147ed0ef4f..d05089abf80 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -1,23 +1,25 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { STREAM_CHUNK_SIZE, JsonRpcErrorCode, RelayErrorCode } from './relay-protocol' +import { JsonRpcErrorCode } from './relay-protocol' import type { FileReadResult } from '../providers/types' +import { + PreMetadataStreamFrameBuffer, + STREAM_READER_INACTIVITY_TIMEOUT_MS, + defaultSshPreMetadataStreamBudget, + defaultSshStreamAssemblyBudget, + type SshPreMetadataStreamBudget, + type SshStreamAssemblyBudget, + createStreamInactivityDeadline +} from './ssh-stream-reader-memory' +import { + createFileStreamSetup, + type FileStreamAssembler, + StreamProtocolError +} from './ssh-file-stream-assembler' + +export { StreamProtocolError } from './ssh-file-stream-assembler' -const RESULT_ENCODING_BASE64 = 'base64' const SENTINEL_STREAM_ID = -1 -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 -const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 - -type StreamMetadataResponse = { - streamId?: number - totalSize: number - isBinary: boolean - isImage?: boolean - mimeType?: string - resultEncoding?: 'base64' | 'utf-8' - empty?: boolean -} - export function isMethodNotFoundError(err: unknown): boolean { if (!err || typeof err !== 'object') { return false @@ -26,16 +28,12 @@ export function isMethodNotFoundError(err: unknown): boolean { return code === JsonRpcErrorCode.MethodNotFound } -export class StreamProtocolError extends Error { - readonly code = RelayErrorCode.StreamProtocolError - constructor(message: string) { - super(message) - } -} - export async function readFileViaStream( mux: SshChannelMultiplexer, - filePath: string + filePath: string, + options?: { inactivityTimeoutMs?: number }, + assemblyBudget: SshStreamAssemblyBudget = defaultSshStreamAssemblyBudget, + preMetadataBudget: SshPreMetadataStreamBudget = defaultSshPreMetadataStreamBudget ): Promise { // Why: subscribe BEFORE awaiting the metadata response so a chunk arriving // immediately after the response cannot beat the listener registration. @@ -55,42 +53,44 @@ export async function readFileViaStream( } return new Promise((resolve, reject) => { - let buffer: Buffer | null = null - let resultEncoding: 'base64' | 'utf-8' = RESULT_ENCODING_BASE64 - let isBinary = false - let isImage: boolean | undefined - let mimeType: string | undefined - let totalSize = 0 - let expectedSeq = 0 - let receivedChunks = 0 - let totalChunks = 0 - let bytesReceived = 0 + let assembler: FileStreamAssembler | null = null let settled = false // Why: chunk/end/error frames may arrive in the same dispatch tick as the // metadata response. Queue them until streamIdRef is set, then drain. - type PendingFrame = - | { kind: 'chunk'; params: Record } - | { kind: 'end'; params: Record } - | { kind: 'error'; params: Record } - const pending: PendingFrame[] = [] + const pending = new PreMetadataStreamFrameBuffer(preMetadataBudget) let metadataReady = false - const cancel = (): void => { - if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { + const inactivityMs = options?.inactivityTimeoutMs ?? STREAM_READER_INACTIVITY_TIMEOUT_MS + const inactivity = createStreamInactivityDeadline(inactivityMs, () => { + fail(new StreamProtocolError(`File stream stalled (>${inactivityMs}ms without data)`)) + }) + + const cancelStreamId = (streamId: number): void => { + if (!mux.isDisposed()) { try { - mux.notify('fs.cancelStream', { streamId: streamIdRef.current }) + mux.notify('fs.cancelStream', { streamId }) } catch { // Best-effort } } } + const cancel = (): void => { + if (streamIdRef.current !== SENTINEL_STREAM_ID) { + cancelStreamId(streamIdRef.current) + } + } + const fail = (err: Error): void => { if (settled) { return } settled = true + inactivity.clear() + pending.clear() + assembler?.release() + assembler = null cancel() cleanup() reject(err) @@ -101,6 +101,8 @@ export async function readFileViaStream( return } settled = true + inactivity.clear() + pending.clear() cleanup() resolve(value) } @@ -113,45 +115,26 @@ export async function readFileViaStream( if (id !== streamIdRef.current) { return } - const seq = params.seq as number - const data = params.data as string - if (typeof seq !== 'number' || typeof data !== 'string') { - fail(new StreamProtocolError(`Malformed chunk for stream ${id}`)) - return - } - if (seq !== expectedSeq) { - fail( - new StreamProtocolError( - `Out-of-order chunk for stream ${id}: expected ${expectedSeq}, got ${seq}` - ) - ) - return - } - const offset = seq * STREAM_CHUNK_SIZE - const decoded = Buffer.from(data, 'base64') - // Why: a short chunk would leave the pre-allocated buffer zero-filled and - // resolve as silently-corrupt data; validate each chunk's exact length. - const expectedLength = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) - if (decoded.length !== expectedLength) { - fail( - new StreamProtocolError( - `Chunk length mismatch for stream ${id}: seq=${seq} expected=${expectedLength} got=${decoded.length}` - ) - ) - return - } - if (!buffer) { + if (!assembler) { fail(new StreamProtocolError(`Chunk arrived before metadata for stream ${id}`)) return } - decoded.copy(buffer, offset) - expectedSeq += 1 - receivedChunks += 1 - bytesReceived += decoded.length + let seq: number + try { + seq = assembler.acceptChunk(params, id) + } catch (error) { + fail(error as Error) + return + } + inactivity.reset() // Why: credit-based flow control — the relay caps unacked chunks so bulk // stream frames cannot queue unbounded ahead of interactive pty.data // frames on the shared SSH channel. Old relays ignore this notification. - mux.notify('fs.streamAck', { streamId: id, seq }) + try { + mux.notify('fs.streamAck', { streamId: id, seq }) + } catch { + // Disposal can race the write; teardown will settle the reader. + } } const handleEnd = (params: Record): void => { @@ -162,38 +145,15 @@ export async function readFileViaStream( if (id !== streamIdRef.current) { return } - if (receivedChunks !== totalChunks) { - fail( - new StreamProtocolError( - `Chunk count mismatch for stream ${id}: expected ${totalChunks}, received ${receivedChunks}` - ) - ) - return - } - // Why: redundant given the per-chunk length + count checks, but kept as a - // last-line invariant guard; never resolve with fewer bytes than declared. - if (bytesReceived !== totalSize) { - fail( - new StreamProtocolError( - `Byte count mismatch for stream ${id}: expected ${totalSize}, received ${bytesReceived}` - ) - ) - return - } - if (!buffer) { + if (!assembler) { fail(new StreamProtocolError(`Stream end before metadata for stream ${id}`)) return } - const content = - resultEncoding === RESULT_ENCODING_BASE64 - ? buffer.toString('base64') - : buffer.toString('utf-8') - succeed({ - content, - isBinary, - ...(isImage !== undefined ? { isImage } : {}), - ...(mimeType !== undefined ? { mimeType } : {}) - }) + try { + succeed(assembler.finish(id)) + } catch (error) { + fail(error as Error) + } } const handleStreamError = (params: Record): void => { @@ -213,7 +173,10 @@ export async function readFileViaStream( const drainPending = (): void => { while (!settled && pending.length > 0) { - const frame = pending.shift()! + const frame = pending.shift() + if (!frame) { + break + } if (frame.kind === 'chunk') { handleChunk(frame.params) } else if (frame.kind === 'end') { @@ -224,10 +187,18 @@ export async function readFileViaStream( } } + const pushPending = ( + kind: 'chunk' | 'end' | 'error', + params: Record + ): void => { + // Why: the stream id is unknown here; overload drops must not let one foreign frame fail every reader. + pending.push({ kind, params }) + } + unsubscribers.push( mux.onNotificationByMethod('fs.streamChunk', (params) => { if (!metadataReady) { - pending.push({ kind: 'chunk', params }) + pushPending('chunk', params) return } handleChunk(params) @@ -236,7 +207,7 @@ export async function readFileViaStream( unsubscribers.push( mux.onNotificationByMethod('fs.streamEnd', (params) => { if (!metadataReady) { - pending.push({ kind: 'end', params }) + pushPending('end', params) return } handleEnd(params) @@ -245,7 +216,7 @@ export async function readFileViaStream( unsubscribers.push( mux.onNotificationByMethod('fs.streamError', (params) => { if (!metadataReady) { - pending.push({ kind: 'error', params }) + pushPending('error', params) return } handleStreamError(params) @@ -269,51 +240,31 @@ export async function readFileViaStream( .request('fs.readFileStream', { filePath, flowControl: 'ack' }) .then((rawMetadata) => { if (settled) { + const streamId = (rawMetadata as { streamId?: unknown } | null)?.streamId + if (typeof streamId === 'number') { + cancelStreamId(streamId) + } return } - const metadata = rawMetadata as StreamMetadataResponse - isBinary = metadata.isBinary - isImage = metadata.isImage - mimeType = metadata.mimeType - resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64 - - if (metadata.empty) { - succeed({ - content: '', - isBinary: metadata.isBinary, - ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), - ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}) - }) - return - } - - if (typeof metadata.streamId !== 'number') { - fail(new StreamProtocolError('Metadata missing streamId for non-empty stream')) - return - } - - const cap = metadata.isBinary ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (metadata.totalSize < 0 || metadata.totalSize > cap) { - streamIdRef.current = metadata.streamId - fail( - new StreamProtocolError( - `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` - ) - ) - return - } - - totalSize = metadata.totalSize - totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE) + let setup try { - buffer = Buffer.alloc(totalSize) - } catch (err) { - streamIdRef.current = metadata.streamId - fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`)) + setup = createFileStreamSetup(rawMetadata, assemblyBudget) + } catch (error) { + const streamId = (rawMetadata as { streamId?: unknown } | null)?.streamId + if (typeof streamId === 'number') { + streamIdRef.current = streamId + } + fail(error as Error) return } - streamIdRef.current = metadata.streamId + if (setup.kind === 'empty') { + succeed(setup.result) + return + } + streamIdRef.current = setup.streamId + assembler = setup.assembler metadataReady = true + inactivity.reset() drainPending() }) .catch((err) => { diff --git a/src/main/ssh/ssh-git-response-stream-assembler.ts b/src/main/ssh/ssh-git-response-stream-assembler.ts new file mode 100644 index 00000000000..7153826ca80 --- /dev/null +++ b/src/main/ssh/ssh-git-response-stream-assembler.ts @@ -0,0 +1,108 @@ +import { RelayErrorCode } from './relay-protocol' +import { type SshStreamAssemblyBudget, base64EncodedLength } from './ssh-stream-reader-memory' + +export class GitResponseStreamError extends Error { + readonly code = RelayErrorCode.StreamProtocolError + constructor(message: string) { + super(message) + } +} + +export class GitResponseStreamAssembler { + private buffer: Buffer | null + private expectedSeq = 0 + private receivedBytes = 0 + private releaseBudget: (() => void) | null + + constructor( + private readonly streamId: number, + private readonly totalBytes: number, + private readonly chunkCount: number, + assemblyBudget: SshStreamAssemblyBudget + ) { + this.releaseBudget = assemblyBudget.reserve(totalBytes) + if (!this.releaseBudget) { + throw new GitResponseStreamError( + `Active SSH stream assembly would exceed ${assemblyBudget.maxBytes} bytes` + ) + } + try { + this.buffer = Buffer.alloc(totalBytes) + } catch (error) { + this.release() + throw new GitResponseStreamError( + `Failed to allocate ${totalBytes} bytes: ${(error as Error).message}` + ) + } + } + + acceptChunk(params: Record): number { + const seq = params.seq + const data = params.data + if ( + typeof seq !== 'number' || + !Number.isSafeInteger(seq) || + seq < 0 || + typeof data !== 'string' + ) { + throw new GitResponseStreamError(`Malformed chunk for git stream ${this.streamId}`) + } + if (seq !== this.expectedSeq) { + throw new GitResponseStreamError( + `Out-of-order chunk for git stream ${this.streamId}: expected ${this.expectedSeq}, got ${seq}` + ) + } + if (this.expectedSeq >= this.chunkCount) { + throw new GitResponseStreamError( + `Git stream ${this.streamId} exceeded declared chunk count ${this.chunkCount}` + ) + } + const remainingBytes = this.totalBytes - this.receivedBytes + if (data.length > base64EncodedLength(remainingBytes)) { + throw new GitResponseStreamError( + `Git stream ${this.streamId} chunk exceeds ${remainingBytes} remaining bytes` + ) + } + const decoded = Buffer.from(data, 'base64') + if (decoded.length > remainingBytes) { + throw new GitResponseStreamError( + `Git stream ${this.streamId} decoded beyond its declared size` + ) + } + if (!this.buffer) { + throw new GitResponseStreamError( + `Chunk arrived after completion for git stream ${this.streamId}` + ) + } + decoded.copy(this.buffer, this.receivedBytes) + this.receivedBytes += decoded.length + this.expectedSeq += 1 + return seq + } + + finish(): unknown { + if (this.expectedSeq !== this.chunkCount || this.receivedBytes !== this.totalBytes) { + throw new GitResponseStreamError( + `Git stream ${this.streamId} incomplete: chunks ${this.expectedSeq}/${this.chunkCount}, bytes ${this.receivedBytes}/${this.totalBytes}` + ) + } + if (!this.buffer) { + throw new GitResponseStreamError(`Git stream ${this.streamId} already completed`) + } + const serialized = this.buffer.toString('utf-8') + this.release() + try { + return JSON.parse(serialized) + } catch (error) { + throw new GitResponseStreamError( + `Git stream ${this.streamId} JSON parse failed: ${String(error)}` + ) + } + } + + release(): void { + this.buffer = null + this.releaseBudget?.() + this.releaseBudget = null + } +} diff --git a/src/main/ssh/ssh-git-response-stream-reader.ts b/src/main/ssh/ssh-git-response-stream-reader.ts index ef63c7a6dc0..94f9d5b8228 100644 --- a/src/main/ssh/ssh-git-response-stream-reader.ts +++ b/src/main/ssh/ssh-git-response-stream-reader.ts @@ -1,31 +1,26 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { RelayErrorCode, isGitResponseStreamMarker } from './relay-protocol' +import { + MAX_GIT_RESPONSE_STREAM_BYTES, + MAX_GIT_RESPONSE_STREAM_CHUNKS, + isGitResponseStreamMarker +} from './relay-protocol' +import { + PreMetadataStreamFrameBuffer, + STREAM_READER_INACTIVITY_TIMEOUT_MS, + defaultSshPreMetadataStreamBudget, + defaultSshStreamAssemblyBudget, + type SshPreMetadataStreamBudget, + type SshStreamAssemblyBudget, + createStreamInactivityDeadline +} from './ssh-stream-reader-memory' +import { + GitResponseStreamAssembler, + GitResponseStreamError +} from './ssh-git-response-stream-assembler' const SENTINEL_STREAM_ID = -1 -/** Reject if no stream frame (chunk/end/error) arrives within this window, - * reset on each frame. mux.request's own timeout only bounds the fast sentinel - * response; without this, a relay pump that breaks on staleness (which sends no - * responseEnd) while the SSH channel stays up would hang the client forever. */ -const STREAM_INACTIVITY_TIMEOUT_MS = 30_000 - -/** Bound transient buffering of other concurrent streams' chunks while this - * reader awaits its sentinel: every reader sees all git.responseChunk frames - * and can't filter by streamId until its own sentinel resolves. Foreign frames - * are dropped on drain anyway; this just caps the pre-sentinel backlog. */ -const MAX_PENDING_FRAMES = 64 - -export class GitResponseStreamError extends Error { - readonly code = RelayErrorCode.StreamProtocolError - constructor(message: string) { - super(message) - } -} - -type PendingFrame = - | { kind: 'chunk'; params: Record } - | { kind: 'end'; params: Record } - | { kind: 'error'; params: Record } +export { GitResponseStreamError } from './ssh-git-response-stream-assembler' /** * Request a git method that may return a large payload, opting into response @@ -49,7 +44,9 @@ export function requestGitStreamable( timeoutMs?: number /** Bounds the post-sentinel reassembly stall; resets on each chunk. */ inactivityTimeoutMs?: number - } + }, + assemblyBudget: SshStreamAssemblyBudget = defaultSshStreamAssemblyBudget, + preMetadataBudget: SshPreMetadataStreamBudget = defaultSshPreMetadataStreamBudget ): Promise { // Why: subscribe to chunk/end/error BEFORE awaiting the sentinel response so a // chunk that lands in the same dispatch tick as the response is not dropped @@ -68,42 +65,22 @@ export function requestGitStreamable( } return new Promise((resolve, reject) => { - const parts: Buffer[] = [] - let expectedSeq = 0 - let receivedBytes = 0 - let totalBytes = 0 - let chunkCount = 0 + let assembler: GitResponseStreamAssembler | null = null let settled = false let metadataReady = false - const pending: PendingFrame[] = [] + const pending = new PreMetadataStreamFrameBuffer(preMetadataBudget) - const inactivityMs = options?.inactivityTimeoutMs ?? STREAM_INACTIVITY_TIMEOUT_MS - let inactivityTimer: ReturnType | null = null - const clearInactivity = (): void => { - if (inactivityTimer) { - clearTimeout(inactivityTimer) - inactivityTimer = null - } - } - // Why: reset on every stream frame so a legitimately long stream is not - // killed, but a wedged stream (no frames arriving) rejects instead of - // hanging the caller forever. - const armInactivity = (): void => { - clearInactivity() - inactivityTimer = setTimeout(() => { - fail( - new GitResponseStreamError( - `Git response stream stalled (>${inactivityMs}ms without data)` - ) - ) - }, inactivityMs) - inactivityTimer.unref?.() - } + const inactivityMs = options?.inactivityTimeoutMs ?? STREAM_READER_INACTIVITY_TIMEOUT_MS + const inactivity = createStreamInactivityDeadline(inactivityMs, () => { + fail( + new GitResponseStreamError(`Git response stream stalled (>${inactivityMs}ms without data)`) + ) + }) - const cancel = (): void => { - if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { + const cancelStreamId = (streamId: number): void => { + if (streamId !== SENTINEL_STREAM_ID && !mux.isDisposed()) { try { - mux.notify('git.cancelResponseStream', { streamId: streamIdRef.current }) + mux.notify('git.cancelResponseStream', { streamId }) } catch { // best-effort } @@ -114,8 +91,11 @@ export function requestGitStreamable( return } settled = true - clearInactivity() - cancel() + inactivity.clear() + pending.clear() + assembler?.release() + assembler = null + cancelStreamId(streamIdRef.current) cleanup() reject(err) } @@ -124,7 +104,8 @@ export function requestGitStreamable( return } settled = true - clearInactivity() + inactivity.clear() + pending.clear() cleanup() resolve(value) } @@ -133,25 +114,22 @@ export function requestGitStreamable( if (settled || p.streamId !== streamIdRef.current) { return } - const seq = p.seq as number - const data = p.data as string - if (typeof seq !== 'number' || typeof data !== 'string') { - fail(new GitResponseStreamError(`Malformed chunk for git stream ${streamIdRef.current}`)) - return - } - if (seq !== expectedSeq) { + if (!assembler) { fail( new GitResponseStreamError( - `Out-of-order chunk for git stream ${streamIdRef.current}: expected ${expectedSeq}, got ${seq}` + `Chunk arrived before metadata for git stream ${streamIdRef.current}` ) ) return } - const decoded = Buffer.from(data, 'base64') - parts.push(decoded) - receivedBytes += decoded.length - expectedSeq += 1 - armInactivity() + let seq: number + try { + seq = assembler.acceptChunk(p) + } catch (error) { + fail(error as Error) + return + } + inactivity.reset() // Why: credit-based flow control — the relay caps unacked chunks so a big // response cannot queue unbounded ahead of interactive pty.data frames. if (!mux.isDisposed()) { @@ -167,22 +145,18 @@ export function requestGitStreamable( if (settled || p.streamId !== streamIdRef.current) { return } - if (expectedSeq !== chunkCount || receivedBytes !== totalBytes) { + if (!assembler) { fail( new GitResponseStreamError( - `Git stream ${streamIdRef.current} incomplete: chunks ${expectedSeq}/${chunkCount}, bytes ${receivedBytes}/${totalBytes}` + `Stream end before metadata for git stream ${streamIdRef.current}` ) ) return } try { - succeed(JSON.parse(Buffer.concat(parts).toString('utf-8'))) - } catch (err) { - fail( - new GitResponseStreamError( - `Git stream ${streamIdRef.current} JSON parse failed: ${String(err)}` - ) - ) + succeed(assembler.finish()) + } catch (error) { + fail(error as Error) } } @@ -195,7 +169,10 @@ export function requestGitStreamable( const drainPending = (): void => { while (!settled && pending.length > 0) { - const frame = pending.shift()! + const frame = pending.shift() + if (!frame) { + break + } if (frame.kind === 'chunk') { handleChunk(frame.params) } else if (frame.kind === 'end') { @@ -206,22 +183,18 @@ export function requestGitStreamable( } } - // Why: pre-sentinel we cannot filter by streamId (our id is unknown yet), so - // every concurrent reader transiently buffers all readers' chunks. Cap the - // backlog by dropping the oldest; foreign frames are dropped on drain anyway, - // and if our own seq-0 were ever dropped the seq check fails loudly rather - // than corrupting. The sentinel normally resolves long before this cap. - const pushPending = (frame: PendingFrame): void => { - pending.push(frame) - if (pending.length > MAX_PENDING_FRAMES) { - pending.shift() - } + const pushPending = ( + kind: 'chunk' | 'end' | 'error', + params: Record + ): void => { + // Why: the stream id is unknown here; overload drops must not let one foreign frame fail every reader. + pending.push({ kind, params }) } unsubscribers.push( mux.onNotificationByMethod('git.responseChunk', (p) => { if (!metadataReady) { - pushPending({ kind: 'chunk', params: p }) + pushPending('chunk', p) return } handleChunk(p) @@ -230,7 +203,7 @@ export function requestGitStreamable( unsubscribers.push( mux.onNotificationByMethod('git.responseEnd', (p) => { if (!metadataReady) { - pushPending({ kind: 'end', params: p }) + pushPending('end', p) return } handleEnd(p) @@ -239,7 +212,7 @@ export function requestGitStreamable( unsubscribers.push( mux.onNotificationByMethod('git.responseError', (p) => { if (!metadataReady) { - pushPending({ kind: 'error', params: p }) + pushPending('error', p) return } handleStreamError(p) @@ -289,21 +262,52 @@ export function requestGitStreamable( void requestPromise .then((result) => { if (settled) { + if (isGitResponseStreamMarker(result)) { + cancelStreamId(result.__orcaGitResponseStream.streamId) + } return } // Old relay / small result: plain single-frame value, no stream follows. if (!isGitResponseStreamMarker(result)) { + if ( + typeof result === 'object' && + result !== null && + '__orcaGitResponseStream' in result + ) { + fail(new GitResponseStreamError('Malformed Git response stream metadata')) + return + } succeed(result) return } const marker = result.__orcaGitResponseStream - totalBytes = marker.totalBytes - chunkCount = marker.chunkCount streamIdRef.current = marker.streamId + if ( + marker.totalBytes > MAX_GIT_RESPONSE_STREAM_BYTES || + marker.chunkCount > MAX_GIT_RESPONSE_STREAM_CHUNKS + ) { + fail( + new GitResponseStreamError( + `Git response stream exceeds client limit (${marker.totalBytes} bytes, ${marker.chunkCount} chunks)` + ) + ) + return + } + try { + assembler = new GitResponseStreamAssembler( + marker.streamId, + marker.totalBytes, + marker.chunkCount, + assemblyBudget + ) + } catch (error) { + fail(error as Error) + return + } metadataReady = true // Why: start the inactivity deadline now — mux.request's timeout only // covered the sentinel; the reassembly phase needs its own guard. - armInactivity() + inactivity.reset() drainPending() }) .catch((err) => fail(err as Error)) diff --git a/src/main/ssh/ssh-key-file.test.ts b/src/main/ssh/ssh-key-file.test.ts new file mode 100644 index 00000000000..7a9212a22ce --- /dev/null +++ b/src/main/ssh/ssh-key-file.test.ts @@ -0,0 +1,37 @@ +import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readSshKeyFile, SSH_KEY_FILE_MAX_BYTES } from './ssh-key-file' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function makeKeyPath(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-ssh-key-')) + roots.push(root) + return join(root, 'id_ed25519') +} + +describe('SSH key file bounds', () => { + it('preserves ordinary key bytes', () => { + const keyPath = makeKeyPath() + writeFileSync(keyPath, 'private-key') + + expect(readSshKeyFile(keyPath)).toEqual(Buffer.from('private-key')) + }) + + it('rejects an oversized sparse key file', () => { + const keyPath = makeKeyPath() + const descriptor = openSync(keyPath, 'w') + ftruncateSync(descriptor, SSH_KEY_FILE_MAX_BYTES + 1) + closeSync(descriptor) + + expect(() => readSshKeyFile(keyPath)).toThrow('exceeds') + }) +}) diff --git a/src/main/ssh/ssh-key-file.ts b/src/main/ssh/ssh-key-file.ts new file mode 100644 index 00000000000..ede46929c2b --- /dev/null +++ b/src/main/ssh/ssh-key-file.ts @@ -0,0 +1,7 @@ +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' + +export const SSH_KEY_FILE_MAX_BYTES = 1024 * 1024 + +export function readSshKeyFile(filePath: string): Buffer { + return readNodeFileSyncWithinLimit(filePath, SSH_KEY_FILE_MAX_BYTES).buffer +} diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts index b49c627b8b3..a1f3f0c195e 100644 --- a/src/main/ssh/ssh-port-forward.test.ts +++ b/src/main/ssh/ssh-port-forward.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { createServer } from 'node:net' import { describe, expect, it, vi, beforeEach } from 'vitest' -import { SshPortForwardManager } from './ssh-port-forward' +import { SSH_FORWARD_CLOSE_CONCURRENCY, SshPortForwardManager } from './ssh-port-forward' const { startSystemSshPortForwardProcessMock } = vi.hoisted(() => ({ startSystemSshPortForwardProcessMock: vi.fn() @@ -246,6 +246,43 @@ describe('SshPortForwardManager', () => { expect(resolved).toBe(true) }) + it.each([ + ['at the limit', SSH_FORWARD_CLOSE_CONCURRENCY], + ['above the limit', SSH_FORWARD_CLOSE_CONCURRENCY + 1] + ])('bounds forward closes %s', async (_, count) => { + let active = 0 + let peak = 0 + let started = 0 + const releases: (() => void)[] = [] + const forwards = Array.from({ length: count }, () => { + const forward = createFakeSystemSshForward() + forward.close.mockImplementation(async () => { + started++ + active++ + peak = Math.max(peak, active) + await new Promise((resolve) => releases.push(resolve)) + active-- + }) + return forward + }) + startSystemSshPortForwardProcessMock.mockImplementation(() => forwards.shift()) + const conn = createSystemSshConn() + for (let index = 0; index < count; index++) { + await manager.addForward('conn-1', conn as never, 3000 + index, '127.0.0.1', 8080) + } + + const removal = manager.removeAllForwards('conn-1') + await vi.waitFor(() => expect(started).toBe(Math.min(count, SSH_FORWARD_CLOSE_CONCURRENCY))) + if (count > SSH_FORWARD_CLOSE_CONCURRENCY) { + releases.shift()?.() + await vi.waitFor(() => expect(started).toBe(count)) + } + releases.splice(0).forEach((release) => release()) + await removal + + expect(peak).toBe(Math.min(count, SSH_FORWARD_CLOSE_CONCURRENCY)) + }) + it('removes an unexpectedly exited system SSH forward and calls the close callback', async () => { const onForwardClosed = vi.fn() manager.setCallbacks({ onForwardClosed }) diff --git a/src/main/ssh/ssh-port-forward.ts b/src/main/ssh/ssh-port-forward.ts index bc151eaab1e..d296c27086e 100644 --- a/src/main/ssh/ssh-port-forward.ts +++ b/src/main/ssh/ssh-port-forward.ts @@ -7,6 +7,9 @@ import type { SshPortForwardProvider, StartedPortForward } from './ssh-port-forward-provider' +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' + +export const SSH_FORWARD_CLOSE_CONCURRENCY = 8 export type { PortForwardEntry } export type { PortForwardCloseReason } @@ -179,7 +182,15 @@ export class SshPortForwardManager { const toRemove = [...this.forwards.entries()] .filter(([, { entry }]) => entry.connectionId === connectionId) .map(([id]) => id) - await Promise.all(toRemove.map((id) => this.removeForwardAsync(id))) + const results = await mapSettledWithConcurrency(toRemove, SSH_FORWARD_CLOSE_CONCURRENCY, (id) => + this.removeForwardAsync(id) + ) + const failedClose = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + if (failedClose) { + throw failedClose.reason + } } dispose(): void { diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts index 09be7c1f686..56dba7139e3 100644 --- a/src/main/ssh/ssh-port-scanner.test.ts +++ b/src/main/ssh/ssh-port-scanner.test.ts @@ -7,6 +7,10 @@ import { } from './ssh-port-scanner' import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import type { DetectedPort } from '../../shared/ssh-types' +import { + SSH_DETECTED_PORTS_MAX_ENTRIES, + SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES +} from '../../shared/ssh-retained-payload-admission' type VisibilityHarness = { visibility: PortScannerWindowVisibility @@ -216,6 +220,26 @@ describe('PortScanner', () => { scanner.dispose() }) + it('bounds remote rows before retaining or publishing a scan', async () => { + const harness = createVisibilityHarness(true) + const remoteRows = Array.from({ length: SSH_DETECTED_PORTS_MAX_ENTRIES + 20 }, (_, index) => ({ + ...port(3000 + index), + processName: 'x'.repeat(SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES + 100) + })) + const { mux } = createMux(() => remoteRows) + const onChanged = vi.fn() + const scanner = new PortScanner(harness.visibility) + + scanner.startScanning('t1', mux, onChanged) + await vi.advanceTimersByTimeAsync(0) + + const retained = scanner.getDetectedPorts('t1') + expect(retained).toHaveLength(SSH_DETECTED_PORTS_MAX_ENTRIES) + expect(retained[0].processName).toHaveLength(SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES) + expect(onChanged).toHaveBeenCalledWith('t1', retained, 'linux') + scanner.dispose() + }) + it('stopScanning halts polling and detaches the visibility listener', async () => { const harness = createVisibilityHarness(true) const { mux, request } = createMux(() => [port(3000)]) diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts index 6404d035f22..abb668095ce 100644 --- a/src/main/ssh/ssh-port-scanner.ts +++ b/src/main/ssh/ssh-port-scanner.ts @@ -1,5 +1,6 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import type { DetectedPort } from '../../shared/ssh-types' +import { admitSshDetectedPorts } from '../../shared/ssh-retained-payload-admission' // Why: every tick walks /proc/*/fd on the remote relay, so cadence is remote // CPU, not just a local timer. 12s cuts steady-state request volume 4x vs the @@ -75,8 +76,10 @@ export class PortScanner { return } + // Why: relay responses cross a provider boundary and are retained between scans. + const admittedPorts = admitSshDetectedPorts(result.ports) const currentPorts = new Map() - for (const p of result.ports) { + for (const p of admittedPorts) { currentPorts.set(`${p.host}:${p.port}`, p) } @@ -87,7 +90,7 @@ export class PortScanner { if (!portsEqual(handle.previousPorts, currentPorts)) { handle.previousPorts = currentPorts handle.intervalMs = SSH_PORT_SCAN_BASE_INTERVAL_MS - onChanged(targetId, result.ports, result.platform) + onChanged(targetId, admittedPorts, result.platform) } else { handle.intervalMs = Math.min(handle.intervalMs * 2, SSH_PORT_SCAN_MAX_INTERVAL_MS) } diff --git a/src/main/ssh/ssh-relay-base-directory-listing.test.ts b/src/main/ssh/ssh-relay-base-directory-listing.test.ts new file mode 100644 index 00000000000..56d9fb676f2 --- /dev/null +++ b/src/main/ssh/ssh-relay-base-directory-listing.test.ts @@ -0,0 +1,121 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + getRelayBaseDirectoryListingCommand, + RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL, + RELAY_BASE_DIRECTORY_MAX_ENTRIES, + RELAY_BASE_DIRECTORY_MAX_UTF8_BYTES +} from './ssh-relay-base-directory-listing' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const posix = getRemoteHostPlatform('linux-x64') +const windows = getRemoteHostPlatform('win32-x64') + +function decodePowerShellCommand(command: string): string { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' +} + +function runPosixListing( + directory: string, + options: { maxEntries: number; maxUtf8Bytes: number } +): string[] { + const result = spawnSync( + '/bin/sh', + ['-c', getRelayBaseDirectoryListingCommand(posix, directory, options)], + { encoding: 'utf8' } + ) + if (result.status !== 0) { + throw new Error(`listing exited ${result.status}: ${result.stderr}`) + } + return result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) +} + +function withTempDirectory(run: (directory: string) => void): void { + const directory = mkdtempSync(join(tmpdir(), 'orca-relay-listing-')) + try { + run(directory) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +describe('relay base-directory listing', () => { + it('streams POSIX entries through fixed entry and byte budgets', () => { + const command = getRelayBaseDirectoryListingCommand(posix, '/home/u/.orca-remote') + + expect(command).toContain('find "$base" -mindepth 1 -maxdepth 1 -print') + expect(command).toContain(`entry_count>${RELAY_BASE_DIRECTORY_MAX_ENTRIES}`) + expect(command).toContain(`output_bytes+line_bytes>${RELAY_BASE_DIRECTORY_MAX_UTF8_BYTES}`) + expect(command).toContain('| sort') + expect(command).not.toContain('ls -1') + }) + + it('allows the exact POSIX entry and output-byte bounds', () => { + withTempDirectory((directory) => { + mkdirSync(join(directory, 'a')) + mkdirSync(join(directory, 'b')) + + expect( + runPosixListing(directory, { + maxEntries: 2, + maxUtf8Bytes: 4 + }).sort() + ).toEqual(['a', 'b']) + }) + }) + + it('emits a sentinel on the first POSIX entry beyond the bound', () => { + withTempDirectory((directory) => { + mkdirSync(join(directory, 'a')) + mkdirSync(join(directory, 'b')) + mkdirSync(join(directory, 'c')) + + expect( + runPosixListing(directory, { + maxEntries: 2, + maxUtf8Bytes: 100 + }) + ).toContain(RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL) + }) + }) + + it('emits a sentinel before exceeding the POSIX output-byte bound', () => { + withTempDirectory((directory) => { + mkdirSync(join(directory, 'aa')) + mkdirSync(join(directory, 'bb')) + + const lines = runPosixListing(directory, { + maxEntries: 2, + maxUtf8Bytes: 5 + }) + + expect(lines).toContain(RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL) + expect( + lines.filter((line) => line !== RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL) + ).toHaveLength(1) + }) + }) + + it('uses a disposable streaming iterator on Windows', () => { + const script = decodePowerShellCommand( + getRelayBaseDirectoryListingCommand(windows, 'C:/Users/u/.orca-remote') + ) + + expect(script).toContain('[System.IO.Directory]::EnumerateDirectories($base)') + expect(script).toContain('.GetEnumerator()') + expect(script).toContain('$iterator.MoveNext()') + expect(script).toContain('$iterator.Dispose()') + expect(script).toContain(`$maxEntries = ${RELAY_BASE_DIRECTORY_MAX_ENTRIES}`) + expect(script).toContain(`$maxBytes = ${RELAY_BASE_DIRECTORY_MAX_UTF8_BYTES}`) + expect(script).toContain('UTF8.GetByteCount($name) + 2') + expect(script).toContain(RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL) + expect(script).not.toContain('Get-ChildItem') + }) +}) diff --git a/src/main/ssh/ssh-relay-base-directory-listing.ts b/src/main/ssh/ssh-relay-base-directory-listing.ts new file mode 100644 index 00000000000..a20eb2897f7 --- /dev/null +++ b/src/main/ssh/ssh-relay-base-directory-listing.ts @@ -0,0 +1,93 @@ +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { shellEscape } from './ssh-connection-utils' + +export const RELAY_BASE_DIRECTORY_MAX_ENTRIES = 4_096 +export const RELAY_BASE_DIRECTORY_MAX_UTF8_BYTES = 256 * 1024 +export const RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL = + '__ORCA_RELAY_BASE_DIRECTORY_LISTING_TOO_LARGE__' + +export function isRelayBaseDirectoryListingLimited(listing: string): boolean { + return listing + .split(/\r?\n/) + .some((line) => line.trim() === RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL) +} + +type RelayBaseDirectoryListingOptions = { + maxEntries?: number + maxUtf8Bytes?: number +} + +function positiveInteger(value: number | undefined, fallback: number, name: string): number { + const resolved = value ?? fallback + if (!Number.isSafeInteger(resolved) || resolved < 1) { + throw new Error(`${name} must be a positive integer`) + } + return resolved +} + +export function getRelayBaseDirectoryListingCommand( + host: RemoteHostPlatform, + baseDir: string, + options?: RelayBaseDirectoryListingOptions +): string { + const maxEntries = positiveInteger( + options?.maxEntries, + RELAY_BASE_DIRECTORY_MAX_ENTRIES, + 'maxEntries' + ) + const maxUtf8Bytes = positiveInteger( + options?.maxUtf8Bytes, + RELAY_BASE_DIRECTORY_MAX_UTF8_BYTES, + 'maxUtf8Bytes' + ) + + if (!isWindowsRemoteHost(host)) { + const awkProgram = [ + '{', + 'entry_count++;', + `if(entry_count>${maxEntries}){print "${RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL}";exit}`, + 'name=$0;sub(/^.*\\//,"",name);', + 'if(substr(name,1,1)==".")next;', + 'line_bytes=length(name)+1;', + `if(output_bytes+line_bytes>${maxUtf8Bytes}){print "${RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL}";exit}`, + 'print name;output_bytes+=line_bytes', + '}' + ].join('') + return [ + `base=${shellEscape(baseDir)};`, + 'if [ -d "$base" ]; then', + 'LC_ALL=C find "$base" -mindepth 1 -maxdepth 1 -print 2>/dev/null', + `| LC_ALL=C awk ${shellEscape(awkProgram)} | sort;`, + 'fi' + ].join(' ') + } + + return powerShellCommand( + [ + `$base = ${powerShellLiteral(baseDir)}`, + `if (Test-Path -LiteralPath $base -PathType Container) {`, + `$maxEntries = ${maxEntries}`, + `$maxBytes = ${maxUtf8Bytes}`, + `$sentinel = ${powerShellLiteral(RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL)}`, + '$entryCount = 0', + '$outputBytes = 0', + '$iterator = ([System.IO.Directory]::EnumerateDirectories($base)).GetEnumerator()', + 'try {', + 'while ($iterator.MoveNext()) {', + 'if ($entryCount -ge $maxEntries) { Write-Output $sentinel; break }', + '$entryCount++', + '$name = [System.IO.Path]::GetFileName([string]$iterator.Current)', + '$lineBytes = [System.Text.Encoding]::UTF8.GetByteCount($name) + 2', + 'if (($outputBytes + $lineBytes) -gt $maxBytes) { Write-Output $sentinel; break }', + 'Write-Output $name', + '$outputBytes += $lineBytes', + '}', + '} finally {', + 'if ($null -ne $iterator) { $iterator.Dispose() }', + '}', + '}' + ].join('\n') + ) +} diff --git a/src/main/ssh/ssh-relay-bounded-marker-commands.test.ts b/src/main/ssh/ssh-relay-bounded-marker-commands.test.ts new file mode 100644 index 00000000000..50ab6e7235f --- /dev/null +++ b/src/main/ssh/ssh-relay-bounded-marker-commands.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + SSH_RELAY_MARKER_MAX_BYTES, + posixReadRelayMarkerAssignment, + powerShellReadRelayMarkerAssignment +} from './ssh-relay-bounded-marker-commands' + +describe('bounded SSH relay marker commands', () => { + it('reads at most one byte past the Windows marker ceiling', () => { + const script = powerShellReadRelayMarkerAssignment('C:/Users/me/.orca-remote/.gc-owner') + + expect(script).toContain('[System.IO.File]::Open') + expect(script).toContain(`New-Object byte[] ${SSH_RELAY_MARKER_MAX_BYTES + 1}`) + expect(script).toContain(`-le ${SSH_RELAY_MARKER_MAX_BYTES}`) + expect(script).not.toContain('Get-Content') + expect(script).not.toContain('ReadAllText') + }) + + it('caps POSIX command substitution before comparing an owner token', () => { + const script = posixReadRelayMarkerAssignment('/home/u/.orca-remote/.gc-owner') + + expect(script).toContain(`bs=${SSH_RELAY_MARKER_MAX_BYTES + 1}`) + expect(script).toContain(`-gt ${SSH_RELAY_MARKER_MAX_BYTES}`) + expect(script).not.toContain('cat ') + }) +}) diff --git a/src/main/ssh/ssh-relay-bounded-marker-commands.ts b/src/main/ssh/ssh-relay-bounded-marker-commands.ts new file mode 100644 index 00000000000..4e052b6c161 --- /dev/null +++ b/src/main/ssh/ssh-relay-bounded-marker-commands.ts @@ -0,0 +1,23 @@ +import { shellEscape } from './ssh-connection-utils' +import { powerShellLiteral } from './ssh-remote-powershell' + +export const SSH_RELAY_MARKER_MAX_BYTES = 1024 + +export function powerShellReadRelayMarkerAssignment(markerPath: string): string { + return [ + '$orcaMarkerValue = $null', + '$orcaMarkerStream = $null', + `try { $orcaMarkerStream = [System.IO.File]::Open(${powerShellLiteral(markerPath)}, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, ([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete))`, + `$orcaMarkerBuffer = New-Object byte[] ${SSH_RELAY_MARKER_MAX_BYTES + 1}`, + '$orcaMarkerCount = $orcaMarkerStream.Read($orcaMarkerBuffer, 0, $orcaMarkerBuffer.Length)', + `if ($orcaMarkerCount -le ${SSH_RELAY_MARKER_MAX_BYTES}) { $orcaMarkerValue = [System.Text.Encoding]::UTF8.GetString($orcaMarkerBuffer, 0, $orcaMarkerCount) }`, + '} catch {} finally { if ($null -ne $orcaMarkerStream) { $orcaMarkerStream.Dispose() } }' + ].join('; ') +} + +export function posixReadRelayMarkerAssignment(markerPath: string): string { + return [ + `orca_marker=$(dd if=${shellEscape(markerPath)} bs=${SSH_RELAY_MARKER_MAX_BYTES + 1} count=1 2>/dev/null) || orca_marker=`, + `if [ "\${#orca_marker}" -gt ${SSH_RELAY_MARKER_MAX_BYTES} ]; then orca_marker=; fi` + ].join('; ') +} diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index 14f063e3bf1..ee89c350629 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -7,6 +7,7 @@ // collapses to a shared dir passes every other unit test and re-introduces // the original "stale daemon serves new client" bug. +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -14,8 +15,10 @@ vi.mock('electron', () => ({ })) vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+v2hash') + existsSync: vi.fn().mockReturnValue(true) +})) +vi.mock('../../shared/relay-version-marker', () => ({ + readRelayVersionMarkerSync: vi.fn().mockReturnValue('0.1.0+v2hash') })) vi.mock('./relay-protocol', () => ({ @@ -61,24 +64,28 @@ function makeMockConnection(): SshConnection { stdout: { on: vi.fn() }, close: vi.fn() }), - sftp: vi.fn().mockResolvedValue({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - on: vi.fn(), - once: vi.fn(), - createWriteStream: vi.fn().mockReturnValue({ - on: vi.fn((_event: string, cb: () => void) => { - if (_event === 'close') { - setTimeout(cb, 0) - } - }), - once: vi.fn((_event: string, cb: () => void) => { - if (_event === 'close') { - setTimeout(cb, 0) - } - }), - end: vi.fn() - }), - end: vi.fn() + // Why: production attaches and removes real SFTP/write-stream listeners, so the fake must be an emitter. + sftp: vi.fn().mockImplementation(() => { + const sftp = new EventEmitter() + return Promise.resolve( + Object.assign(sftp, { + mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), + // Shell home and SFTP start directory agree here, so no namespace redirect applies. + realpath: vi.fn((_p: string, cb: (err: Error | null, resolved: string) => void) => + cb(null, '/home/u') + ), + lstat: vi.fn((_p: string, cb: (err: Error | null) => void) => + cb(Object.assign(new Error('No such file'), { code: 2 })) + ), + createWriteStream: vi.fn().mockImplementation(() => { + const ws = new EventEmitter() + return Object.assign(ws, { + end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)) + }) + }), + end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0)) + }) + ) }) } as unknown as SshConnection } diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 4df9c8ad28a..2762abb27b0 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it, vi } from 'vitest' import type { ClientChannel } from 'ssh2' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { shouldProbeBuildToolchainAfterNativeDepsFailure } from './ssh-relay-build-toolchain' -import { RELAY_SENTINEL } from './relay-protocol' +import { + HEADER_LENGTH, + MAX_BUFFERED_FRAME_CHUNKS, + MAX_MESSAGE_SIZE, + RELAY_SENTINEL +} from './relay-protocol' import { RelayVersionMismatchError, RELAY_EXIT_CODE_VERSION_MISMATCH @@ -204,9 +209,68 @@ describe('waitForSentinel', () => { expect(Buffer.concat(chunks)).toEqual(postSentinelPayload) expect(channel.close).not.toHaveBeenCalled() }) + + it('preserves the exact post-sentinel payload at the pending transport cap', async () => { + const channel = createMockChannel() + const transportPromise = waitForSentinel(channel) + const payload = Buffer.alloc((MAX_MESSAGE_SIZE + HEADER_LENGTH) * 2, 'p') + + channel.emit('data', Buffer.from(RELAY_SENTINEL)) + channel.emit('data', payload) + + const transport = await transportPromise + const chunks: Buffer[] = [] + transport.onData((chunk) => chunks.push(chunk)) + + expect(chunks).toHaveLength(1) + expect(chunks[0]?.equals(payload)).toBe(true) + expect(channel.close).not.toHaveBeenCalled() + }) + + it('closes before retaining post-sentinel bytes beyond the pending transport cap', async () => { + const channel = createMockChannel() + const transportPromise = waitForSentinel(channel) + + channel.emit('data', Buffer.from(RELAY_SENTINEL)) + channel.emit('data', Buffer.alloc((MAX_MESSAGE_SIZE + HEADER_LENGTH) * 2, 'p')) + channel.emit('data', Buffer.from('overflow')) + + const transport = await transportPromise + const onData = vi.fn() + const onClose = vi.fn() + transport.onData(onData) + transport.onClose(onClose) + + expect(onData).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledOnce() + expect(channel.close).toHaveBeenCalledOnce() + }) + + it('closes before retaining too many post-sentinel fragments', async () => { + const channel = createMockChannel() + const transportPromise = waitForSentinel(channel) + + channel.emit('data', Buffer.from(RELAY_SENTINEL)) + for (let index = 0; index <= MAX_BUFFERED_FRAME_CHUNKS; index++) { + channel.emit('data', Buffer.from('p')) + } + + const transport = await transportPromise + const onData = vi.fn() + const onClose = vi.fn() + transport.onData(onData) + transport.onClose(onClose) + + expect(onData).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledOnce() + expect(channel.close).toHaveBeenCalledOnce() + }) }) describe('execCommand', () => { + const installMarkerToken = 'a'.repeat(32) + const installMarkerPath = `/home/u/.install-lock/.sftp-namespace-${installMarkerToken}` + it('waits for channel close before rejecting a timed-out remote command', async () => { vi.useFakeTimers() try { @@ -271,6 +335,42 @@ describe('execCommand', () => { expect(channel.stderr.listenerCount('data')).toBe(0) }) + it('redacts install-owner marker tokens from command failures', async () => { + const channel = createMockChannel() + const conn = { + exec: vi.fn().mockResolvedValue(channel) + } + const commandPromise = execCommand(conn as never, `touch '${installMarkerPath}'`) + + await Promise.resolve() + channel.emit('data', Buffer.from(`touch failed for ${installMarkerPath}\n`)) + channel.emit('close', 1) + + const error = await execCommandRejection(commandPromise) + expect(error.message).toContain('.sftp-namespace-[redacted]') + expect(error.message).not.toContain(installMarkerToken) + }) + + it('redacts install-owner marker tokens from timeout errors', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const conn = { exec: vi.fn().mockResolvedValue(channel) } + const commandPromise = execCommand(conn as never, `touch '${installMarkerPath}'`, { + timeoutMs: 1_000 + }) + + await vi.advanceTimersByTimeAsync(1_000) + channel.emit('close', 0) + + const error = await execCommandRejection(commandPromise) + expect(error.message).toContain('.sftp-namespace-[redacted]') + expect(error.message).not.toContain(installMarkerToken) + } finally { + vi.useRealTimers() + } + }) + it('surfaces stdout alongside stderr on nonzero exit instead of masking it', async () => { const channel = createMockChannel() const conn = { diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index 80d2b78ced2..9294707af60 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -1,6 +1,12 @@ import type { ClientChannel } from 'ssh2' import { createSshOperationAbortError } from './ssh-connection-utils' -import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' +import { + HEADER_LENGTH, + MAX_BUFFERED_FRAME_CHUNKS, + MAX_MESSAGE_SIZE, + RELAY_SENTINEL, + RELAY_SENTINEL_TIMEOUT_MS +} from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch' @@ -10,6 +16,8 @@ export { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-exe // ── Sentinel detection ──────────────────────────────────────────────── const MAX_RELAY_STARTUP_BUFFER_BYTES = 64 * 1024 +// One maximum frame may be coalesced with the start of the next. +const MAX_PENDING_RELAY_BYTES = (MAX_MESSAGE_SIZE + HEADER_LENGTH) * 2 const RELAY_SENTINEL_BUFFER = Buffer.from(RELAY_SENTINEL, 'utf-8') export function waitForSentinel( @@ -162,19 +170,35 @@ export function waitForSentinel( notifyClosed() }) - // Why: data arriving in the same TCP chunk as the sentinel is buffered - // here. It's delivered on the first onData registration rather than - // immediately after resolve, because resolve schedules a microtask — - // the caller's `await` hasn't resumed yet, so no callbacks are - // registered when the synchronous code after resolve runs. - let pendingAfterSentinel: Buffer | null = null + // The caller cannot subscribe until the resolved promise resumes. + let pendingAfterSentinel: Buffer[] = [] + let pendingAfterSentinelBytes = 0 + + const bufferAfterSentinel = (data: Buffer): void => { + if (closedAfterSentinel) { + return + } + if ( + data.length > MAX_PENDING_RELAY_BYTES - pendingAfterSentinelBytes || + pendingAfterSentinel.length >= MAX_BUFFERED_FRAME_CHUNKS + ) { + pendingAfterSentinel = [] + pendingAfterSentinelBytes = 0 + notifyClosed() + channel.close() + return + } + pendingAfterSentinel.push(data) + pendingAfterSentinelBytes += data.length + } channel.on('data', (data: Buffer) => { if (sentinelReceived) { + if (closedAfterSentinel) { + return + } if (dataCallbacks.length === 0) { - pendingAfterSentinel = pendingAfterSentinel - ? Buffer.concat([pendingAfterSentinel, data]) - : data + bufferAfterSentinel(data) } else { for (const cb of dataCallbacks) { cb(data) @@ -211,19 +235,16 @@ export function waitForSentinel( const afterSentinel = data.subarray(Math.max(0, afterSentinelOffset)) if (afterSentinel.length > 0) { - pendingAfterSentinel = afterSentinel + bufferAfterSentinel(afterSentinel) } const transport: MultiplexerTransport = { write: (buf: Buffer) => channel.stdin.write(buf), onData: (cb) => { dataCallbacks.push(cb) - // Why: deliver buffered post-sentinel data to the first - // subscriber. This is the multiplexer constructor, which - // registers onData synchronously — the data is guaranteed - // to reach the decoder before any other frames arrive. - if (pendingAfterSentinel) { - const buf = pendingAfterSentinel - pendingAfterSentinel = null + if (pendingAfterSentinel.length > 0) { + const buf = Buffer.concat(pendingAfterSentinel, pendingAfterSentinelBytes) + pendingAfterSentinel = [] + pendingAfterSentinelBytes = 0 cb(buf) } }, diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 28fe2a3e49c..eef9d287d49 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -64,7 +64,8 @@ vi.mock('./ssh-relay-versioned-install', () => ({ })) vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined) + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' })) vi.mock('./ssh-relay-repair-lock', () => ({ @@ -101,7 +102,11 @@ function extractWindowsSockPath(script: string): string { } function extractWindowsMarkerPath(script: string): string { - return /-LiteralPath\s+'([^']*\.windows-active-pipe[^']*)'/.exec(script)?.[1] ?? '' + return ( + /(?:-LiteralPath\s+|\[System\.IO\.File\]::Open\()'([^']*\.windows-active-pipe[^']*)'/.exec( + script + )?.[1] ?? '' + ) } function makeMockConnection(): SshConnection { @@ -895,7 +900,11 @@ describe('deployAndLaunchRelay', () => { const markerPaths = mockExecCommand.mock.calls .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => Boolean(script?.includes('Get-Content'))) + .filter((script): script is string => + Boolean( + script?.includes('[System.IO.File]::Open') && script.includes('.windows-active-pipe') + ) + ) .map(extractWindowsMarkerPath) expect(markerPaths).toHaveLength(2) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index f2154d1eb13..52db7747fc5 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -6,11 +6,19 @@ import type { SshConnection } from './ssh-connection' import type { RelayPlatform } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { - uploadDirectory, waitForSentinel, execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' +import { + createRelayInstallMarkerCommand, + createRelayInstallNamespace, + makeRelayInstallDirectoryCommand, + relayHomeRelativeDir, + relaySftpNamespaceMapping, + type RelayInstallNamespace +} from './ssh-relay-install-namespace' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { readLocalFullVersion, @@ -36,7 +44,6 @@ import { } from './ssh-relay-build-toolchain' import { commandWithNodePath, - makeRemoteDirectoryCommand, makeRemoteExecutableCommand, readRemoteHomeCommand, removeRemoteFileCommand @@ -64,6 +71,8 @@ import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' +import { powerShellReadRelayMarkerAssignment } from './ssh-relay-bounded-marker-commands' +import { windowsRelayTailLogCommand } from './ssh-windows-log-tail-command' export type RelayDeployResult = { transport: MultiplexerTransport @@ -311,6 +320,9 @@ async function deployAndLaunchRelayAttempt( console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) + // Why: derive the home-relative suffix once — recomputing it by stripping the shell home breaks on a split namespace. + const homeRelativeRelayDir = relayHomeRelativeDir(fullVersion) + let ownsInstallLock = false let launchGcClaimToken: string | undefined if (alreadyInstalled) { @@ -320,6 +332,7 @@ async function deployAndLaunchRelayAttempt( platform, hostPlatform, nodePath, + homeRelativeRelayDir, deploySignal ) ownsInstallLock = launchFence.ownsInstallLock @@ -336,9 +349,23 @@ async function deployAndLaunchRelayAttempt( signal: deploySignal })) ) { + const installNamespace = createInstallNamespaceIfSupported( + conn, + hostPlatform, + homeRelativeRelayDir + ) + onProgress?.('Uploading relay...') console.log('[ssh-relay] Uploading relay...') - await uploadRelay(conn, platform, remoteRelayDir, fullVersion, hostPlatform, deploySignal) + await uploadRelay( + conn, + platform, + remoteRelayDir, + fullVersion, + hostPlatform, + deploySignal, + installNamespace + ) console.log('[ssh-relay] Upload complete') onProgress?.('Installing native dependencies...') @@ -349,7 +376,9 @@ async function deployAndLaunchRelayAttempt( platform, hostPlatform, nodePath, - deploySignal + deploySignal, + [], + installNamespace ) console.log('[ssh-relay] Native deps installed') @@ -421,7 +450,8 @@ async function uploadRelay( remoteDir: string, fullVersion: string, hostPlatform: RemoteHostPlatform, - signal?: AbortSignal + signal?: AbortSignal, + namespace?: RelayInstallNamespace ): Promise { const localRelayDir = getLocalRelayPath(platform) if (!localRelayDir || !existsSync(localRelayDir)) { @@ -431,11 +461,20 @@ async function uploadRelay( ) } - await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir), { - signal - }) + // Why: the install-owner marker rides along with mkdir, so a standard install spends no extra exec channel on it. + await execHostCommand( + conn, + hostPlatform, + makeRelayInstallDirectoryCommand(hostPlatform, remoteDir, namespace), + { signal } + ) - await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform, signal) + await uploadRelayDirectory(conn, localRelayDir, remoteDir, hostPlatform, { + signal, + sftpNamespace: namespace + ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir) + : undefined + }) if (!isWindowsRemoteHost(hostPlatform)) { await execHostCommand( @@ -447,60 +486,36 @@ async function uploadRelay( } // Why: write .version via SFTP not shell to avoid quoting content-hashed versions; the daemon reads it to validate the wire handshake. - await writeRemoteFile( + await writeRelayFile( conn, hostPlatform, joinRemotePath(hostPlatform, remoteDir, '.version'), fullVersion, - signal + { + signal, + sftpNamespace: namespace + ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, '.version') + : undefined + } ) } -async function uploadDirectoryForConnection( - conn: SshConnection, - localRelayDir: string, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - if (typeof conn.uploadDirectory === 'function') { - await conn.uploadDirectory(localRelayDir, remoteDir, { hostPlatform, signal }) - return - } - - const sftp = await conn.sftp() - try { - await uploadDirectory(sftp, localRelayDir, remoteDir) - } finally { - sftp.end() - } -} - -async function writeRemoteFile( +/** + * A marker is only meaningful where a split namespace can occur and where Orca + * owns the SFTP session: POSIX hosts reached over the bundled ssh2 transport. + */ +function createInstallNamespaceIfSupported( conn: SshConnection, hostPlatform: RemoteHostPlatform, - remotePath: string, - contents: string, - signal?: AbortSignal -): Promise { - if (typeof conn.writeFile === 'function') { - await conn.writeFile(remotePath, contents, { hostPlatform, signal }) - return - } - - const sftp = await conn.sftp() - try { - await new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(remotePath) - // .once: a late session 'error' after resolve/reject would otherwise be unhandled and crash main. - sftp.once('error', reject) - ws.once('close', resolve) - ws.once('error', reject) - ws.end(contents) - }) - } finally { - sftp.end() + homeRelativeRelayDir: string +): RelayInstallNamespace | undefined { + if (isWindowsRemoteHost(hostPlatform)) { + return undefined } + // A connection double without the transport accessor is an ssh2 connection. + const usesSystemSsh = + typeof conn.usesSystemSshTransport === 'function' ? conn.usesSystemSshTransport() : false + return usesSystemSsh ? undefined : createRelayInstallNamespace(homeRelativeRelayDir) } const NODE_PTY_VERSION = '1.1.0' @@ -576,6 +591,7 @@ async function repairInstalledNativeDeps( platform: RelayPlatform, hostPlatform: RemoteHostPlatform, nodePath: string, + homeRelativeRelayDir: string, signal?: AbortSignal ): Promise<{ ownsInstallLock: boolean; gcClaimToken?: string }> { const initialProbe = await probeRequiredNativeDeps( @@ -627,6 +643,14 @@ async function repairInstalledNativeDeps( // Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir. const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) if (!probe.available) { + // Why: only stamp ownership once the locked recheck proves this connection is the one about to write. + const repairNamespace = await createRepairInstallMarker( + conn, + hostPlatform, + remoteDir, + homeRelativeRelayDir, + signal + ) await installNativeDeps( conn, remoteDir, @@ -634,7 +658,8 @@ async function repairInstalledNativeDeps( hostPlatform, nodePath, signal, - probe.missing + probe.missing, + repairNamespace ) await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) } @@ -652,6 +677,42 @@ async function repairInstalledNativeDeps( } } +/** + * Stamp this connection as the install owner during repair. Marker creation is + * best-effort: without it the writes simply keep using the shell path. + */ +async function createRepairInstallMarker( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + remoteDir: string, + homeRelativeRelayDir: string, + signal?: AbortSignal +): Promise { + const namespace = createInstallNamespaceIfSupported(conn, hostPlatform, homeRelativeRelayDir) + if (!namespace) { + return undefined + } + try { + await execHostCommand( + conn, + hostPlatform, + createRelayInstallMarkerCommand(namespace, hostPlatform, remoteDir), + { signal } + ) + return namespace + } catch (err) { + // Why: an unconfirmed termination still owes the caller its lock semantics; only a confirmed failure degrades to shell paths. + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() + console.warn( + `[ssh-relay] SFTP namespace marker unavailable at ${remoteDir}; retaining shell paths` + ) + return undefined + } +} + async function acquireRelayLaunchGcFence( conn: SshConnection, remoteDir: string, @@ -689,7 +750,8 @@ async function installNativeDeps( hostPlatform: RemoteHostPlatform, nodePath: string, signal?: AbortSignal, - resetDeps: RelayNativeDepName[] = [] + resetDeps: RelayNativeDepName[] = [], + namespace?: RelayInstallNamespace ): Promise { // Why: node-pty's prebuild spawns `node` as a child, so node must be in PATH (commandWithNodePath) or it fails exit 127. // Why: npm init -y rejects '+' in content-hashed dir names, so write a fixed minimal package.json instead. @@ -702,12 +764,17 @@ async function installNativeDeps( dependencies: RELAY_NATIVE_DEPS, allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST })}\n` - await writeRemoteFile( + await writeRelayFile( conn, hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'package.json'), pkgJson, - signal + { + signal, + sftpNamespace: namespace + ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, 'package.json') + : undefined + } ) try { @@ -1164,7 +1231,7 @@ async function readWindowsActiveRelayEndpoint( conn, hostPlatform, powerShellCommand( - `if (Test-Path -LiteralPath ${powerShellLiteral(markerPath)} -PathType Leaf) { Get-Content -LiteralPath ${powerShellLiteral(markerPath)} -Raw -ErrorAction SilentlyContinue }` + `${powerShellReadRelayMarkerAssignment(markerPath)}; if ($null -ne $orcaMarkerValue) { [Console]::Out.Write($orcaMarkerValue) }` ), { signal } ).catch(() => { @@ -1518,14 +1585,3 @@ function windowsRelayWaitCommand( ].join(' ') ) } - -function windowsRelayTailLogCommand(logFile: string, errFile: string): string { - const script = [ - `$out = if (Test-Path -LiteralPath ${powerShellLiteral(logFile)}) { Get-Content -LiteralPath ${powerShellLiteral(logFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stdout log)' }`, - `$err = if (Test-Path -LiteralPath ${powerShellLiteral(errFile)}) { Get-Content -LiteralPath ${powerShellLiteral(errFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stderr log)' }`, - 'Write-Output $out', - "Write-Output '--- stderr ---'", - 'Write-Output $err' - ].join('; ') - return powerShellCommand(script) -} diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts index e4bfd8bc042..c631cd2d41a 100644 --- a/src/main/ssh/ssh-relay-exec-command.ts +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -1,5 +1,10 @@ +import type { ClientChannel } from 'ssh2' import type { SshConnection } from './ssh-connection' import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' +import { + redactRelayInstallMarkerError, + redactRelayInstallMarkerTokens +} from './ssh-relay-install-marker' import type { SystemSshCommandChannel } from './system-ssh-command' const EXEC_TIMEOUT_MS = 30_000 @@ -36,7 +41,14 @@ export async function execCommand( // Why: reconnect/disconnect can flip the connection back to ssh2 before a // killed local OpenSSH child emits close; the channel's transport is immutable. const openedWithSystemSsh = conn.usesSystemSshTransport?.() === true - const channel = await conn.exec(command, execOptions) + let channel: ClientChannel + try { + channel = await conn.exec(command, execOptions) + } catch (error) { + // Preserve identity/classifier fields while removing install-owner tokens. + redactRelayInstallMarkerError(error) + throw error + } return new Promise((resolve, reject) => { let stdout = '' let stderr = '' @@ -98,7 +110,10 @@ export async function execCommand( }, COMMAND_CLOSE_GRACE_MS) channel.close() } - const fail = (err: Error): void => requestTermination(err) + const fail = (err: Error): void => { + redactRelayInstallMarkerError(err) + requestTermination(err) + } const onAbort = (): void => requestTermination(createSshOperationAbortError()) const onStdoutData = (data: Buffer): void => { stdout = appendExecOutputTail(stdout, data.toString('utf-8')) @@ -126,14 +141,25 @@ export async function execCommand( } else if (code !== 0) { // Why: on the system-ssh transport channel.stderr carries local OpenSSH // client noise; preferring it masks the real failure in stdout (2>&1). - const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n') - settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`)) + const output = redactRelayInstallMarkerTokens( + [stderr.trim(), stdout.trim()].filter(Boolean).join('\n') + ) + settle( + reject, + new Error( + `Command "${redactRelayInstallMarkerTokens(command)}" failed (exit ${code}): ${output}` + ) + ) } else { settle(resolve, stdout) } } const timeout = setTimeout(() => { - requestTermination(new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`)) + requestTermination( + new Error( + `Command "${redactRelayInstallMarkerTokens(command)}" timed out after ${timeoutMs / 1000}s` + ) + ) }, timeoutMs) // Why: remote reboot tears down exec channels with stream errors. Without diff --git a/src/main/ssh/ssh-relay-gc-claim.ts b/src/main/ssh/ssh-relay-gc-claim.ts index d0c8cc1221f..ddc6b178c5b 100644 --- a/src/main/ssh/ssh-relay-gc-claim.ts +++ b/src/main/ssh/ssh-relay-gc-claim.ts @@ -9,6 +9,10 @@ import { } from './ssh-relay-install-lock-commands' import { removeRemoteTreeCommand } from './ssh-remote-commands' import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + posixReadRelayMarkerAssignment, + powerShellReadRelayMarkerAssignment +} from './ssh-relay-bounded-marker-commands' import { getRemoteHostPlatform, isWindowsRemoteHost, @@ -131,9 +135,9 @@ export async function isRelayGcClaimOwned( const ownerPath = joinRemotePath(host, relayGcClaimPath(remoteRelayDir), RELAY_GC_OWNER_NAME) const command = isWindowsRemoteHost(host) ? powerShellCommand( - `if ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }` + `${powerShellReadRelayMarkerAssignment(ownerPath)}; if ($orcaMarkerValue -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }` ) - : `test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" = ${shellEscape(token)} && echo OWNED || echo LOST` + : `${posixReadRelayMarkerAssignment(ownerPath)}; test "$orca_marker" = ${shellEscape(token)} && echo OWNED || echo LOST` const output = await execHostCommand(conn, host, command).catch(() => 'LOST') return output.trim() === 'OWNED' } @@ -152,15 +156,17 @@ export async function releaseRelayGcClaim( ? powerShellCommand( `$claim = ${powerShellLiteral(claimPath)}; ` + `if (-not (Test-Path -LiteralPath $claim)) { 'RELEASED' } ` + - `elseif ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -cne ${powerShellLiteral(token)}) { 'LOST' } ` + + `else { ${powerShellReadRelayMarkerAssignment(ownerPath)}; ` + + `if ($orcaMarkerValue -cne ${powerShellLiteral(token)}) { 'LOST' } ` + 'else { try { Remove-Item -LiteralPath $claim -Recurse -Force -ErrorAction Stop } catch {}; ' + - "if (Test-Path -LiteralPath $claim) { 'UNKNOWN' } else { 'RELEASED' } }" + "if (Test-Path -LiteralPath $claim) { 'UNKNOWN' } else { 'RELEASED' } } }" ) : [ `if ! test -e ${shellEscape(claimPath)}; then echo RELEASED;`, - `elif test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" != ${shellEscape(token)}; then echo LOST;`, + `else ${posixReadRelayMarkerAssignment(ownerPath)};`, + `if test "$orca_marker" != ${shellEscape(token)}; then echo LOST;`, `else ${removeRemoteTreeCommand(host, claimPath)} 2>/dev/null;`, - `if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi` + `if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi; fi` ].join(' ') const output = await execHostCommand(conn, host, command).catch(() => 'UNKNOWN') switch (output.trim()) { diff --git a/src/main/ssh/ssh-relay-install-lock-commands.ts b/src/main/ssh/ssh-relay-install-lock-commands.ts index d6eab561a2d..05891683243 100644 --- a/src/main/ssh/ssh-relay-install-lock-commands.ts +++ b/src/main/ssh/ssh-relay-install-lock-commands.ts @@ -92,7 +92,10 @@ function posixStealInstallLockCommand(lockDir: string, staleAfterSeconds: number 'steal="$steal_root.$steal_generation";', 'done;', 'if [ "$owns_steal" = 1 ]; then', - `trap 'rm -rf "$steal_root".* 2>/dev/null || true; rm -rf "$lock_tombstone" 2>/dev/null || true' EXIT;`, + 'steal_parent=${steal_root%/*};', + 'steal_leaf=${steal_root##*/};', + '[ "$steal_parent" != "$steal_root" ] || steal_parent=.;', + `trap 'find "$steal_parent" -mindepth 1 -maxdepth 1 -name "$steal_leaf.*" -exec rm -rf {} + 2>/dev/null || true; rm -rf "$lock_tombstone" 2>/dev/null || true' EXIT;`, `${posixLockIdentityAssignment(lockDir, 'current_key')} && current_mtime=\${current_key%%:*} && current_now=$(date +%s) && current_age=$((current_now - current_mtime)) || current_age=0;`, `if [ "$current_key" = "$lock_key" ] && [ "\${current_age:-0}" -gt ${staleAfterSeconds} ] 2>/dev/null; then`, `lock_tombstone=${escapedLockDir}.tombstone.$$.$(date +%s);`, diff --git a/src/main/ssh/ssh-relay-install-marker.ts b/src/main/ssh/ssh-relay-install-marker.ts new file mode 100644 index 00000000000..a5ff95bd4e4 --- /dev/null +++ b/src/main/ssh/ssh-relay-install-marker.ts @@ -0,0 +1,29 @@ +import { randomBytes } from 'node:crypto' + +const SFTP_NAMESPACE_MARKER_PREFIX = '.sftp-namespace-' +const SFTP_NAMESPACE_MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/giu +const MARKER_TOKEN_BYTES = 16 + +export function createRelayInstallMarkerFileName(): string { + return `${SFTP_NAMESPACE_MARKER_PREFIX}${randomBytes(MARKER_TOKEN_BYTES).toString('hex')}` +} + +export function redactRelayInstallMarkerTokens(value: string): string { + return value.replace(SFTP_NAMESPACE_MARKER_PATTERN, `${SFTP_NAMESPACE_MARKER_PREFIX}[redacted]`) +} + +export function redactRelayInstallMarkerError(error: unknown): void { + if (!(error instanceof Error)) { + return + } + const redactedMessage = redactRelayInstallMarkerTokens(error.message) + if (redactedMessage !== error.message) { + error.message = redactedMessage + } + if (error.stack) { + const redactedStack = redactRelayInstallMarkerTokens(error.stack) + if (redactedStack !== error.stack) { + error.stack = redactedStack + } + } +} diff --git a/src/main/ssh/ssh-relay-install-namespace.test.ts b/src/main/ssh/ssh-relay-install-namespace.test.ts new file mode 100644 index 00000000000..7d7dd0be5e0 --- /dev/null +++ b/src/main/ssh/ssh-relay-install-namespace.test.ts @@ -0,0 +1,151 @@ +// Why: the shell path and the SFTP-relative path must be built from the same +// validated segments, and the install-owner marker is what proves a redirected +// directory belongs to this install rather than an unrelated same-version one. + +import { describe, expect, it } from 'vitest' +import { + createRelayInstallMarkerCommand, + createRelayInstallNamespace, + makeRelayInstallDirectoryCommand, + relayHomeRelativeDir, + relayInstallMarkerShellPath, + relayRemoteDirSegments, + relaySftpNamespaceMapping +} from './ssh-relay-install-namespace' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { computeRemoteRelayDir } from './ssh-relay-versioned-install' + +const LINUX = getRemoteHostPlatform('linux-x64') +const WINDOWS = getRemoteHostPlatform('win32-x64') +const VERSION = '0.1.0+abc123' +const SHELL_RELAY_DIR = `/var/services/homes/alice/.orca-remote/relay-${VERSION}` + +describe('relayRemoteDirSegments', () => { + it('builds the two segments every relay path shares', () => { + expect(relayRemoteDirSegments(VERSION, 'posix')).toEqual(['.orca-remote', `relay-${VERSION}`]) + }) + + it('produces a home-relative dir that matches the shell dir suffix', () => { + expect(SHELL_RELAY_DIR.endsWith(`/${relayHomeRelativeDir(VERSION)}`)).toBe(true) + }) + + it.each([ + ['a path separator', '1.0/../etc'], + ['a NUL byte', '1.0\0'], + ['a carriage return', '1.0\rmalicious'], + ['a line feed', '1.0\nmalicious'] + ])('rejects %s in the version segment', (_label, version) => { + expect(() => relayRemoteDirSegments(version, 'posix')).toThrow('Unsafe remote path segment') + }) + + it('applies Windows-specific segment rules on the windows flavor', () => { + expect(() => relayRemoteDirSegments('1.0 ', 'windows')).toThrow('Unsafe remote path segment') + expect(() => relayRemoteDirSegments('1.0 ', 'posix')).not.toThrow() + }) +}) + +describe('computeRemoteRelayDir agreement', () => { + it('ends with the suffix the SFTP-relative builder produces', () => { + // Why: a split namespace rebuilds the path from the home-relative suffix, so the two must agree. + expect(computeRemoteRelayDir('/var/services/homes/alice', VERSION)).toBe(SHELL_RELAY_DIR) + expect(SHELL_RELAY_DIR.endsWith(`/${relayHomeRelativeDir(VERSION)}`)).toBe(true) + }) + + it.each([ + ['a path separator', '0.1.0/../etc'], + ['a NUL byte', '0.1.0\0'], + ['a line feed', '0.1.0\nrm -rf /'] + ])('rejects %s in the version rather than building a path', (_label, version) => { + expect(() => computeRemoteRelayDir('/home/u', version)).toThrow('Unsafe remote path segment') + }) + + it('applies Windows segment rules on the windows flavor', () => { + expect(computeRemoteRelayDir('C:\\Users\\u', VERSION, 'windows')).toBe( + `C:/Users/u/.orca-remote/relay-${VERSION}` + ) + expect(() => computeRemoteRelayDir('C:\\Users\\u', '0.1.0 ', 'windows')).toThrow( + 'Unsafe remote path segment' + ) + }) +}) + +describe('createRelayInstallNamespace', () => { + it('mints an unguessable 128-bit marker name per install', () => { + const first = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) + const second = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) + + expect(first.markerFileName).toMatch(/^\.sftp-namespace-[0-9a-f]{32}$/) + expect(first.markerFileName).not.toBe(second.markerFileName) + expect(first.homeRelativeRelayDir).toBe(`.orca-remote/relay-${VERSION}`) + }) +}) + +describe('relaySftpNamespaceMapping', () => { + const namespace = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) + + it('maps the bundle directory itself when no file name is given', () => { + const mapping = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR) + + expect(mapping.homeRelativePath).toBe(`.orca-remote/relay-${VERSION}`) + expect(mapping.homeRelativeProbePath).toBe( + `.orca-remote/relay-${VERSION}/.install-lock/${namespace.markerFileName}` + ) + expect(mapping.shellProbePath).toBe( + `${SHELL_RELAY_DIR}/.install-lock/${namespace.markerFileName}` + ) + }) + + it('maps a file inside the bundle directory', () => { + const mapping = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, 'package.json') + + expect(mapping.homeRelativePath).toBe(`.orca-remote/relay-${VERSION}/package.json`) + }) + + it('shares one marker across every write of an install', () => { + const bundle = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR) + const version = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, '.version') + + expect(version.shellProbePath).toBe(bundle.shellProbePath) + expect(version.homeRelativeProbePath).toBe(bundle.homeRelativeProbePath) + }) + + it.each(['nested/file', '..', '', 'line\nbreak'])( + 'rejects an unsafe relative file name %j at mapping construction', + (relativeFileName) => { + expect(() => + relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, relativeFileName) + ).toThrow('Unsafe remote path segment') + } + ) +}) + +describe('install directory command', () => { + const namespace = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) + + it('folds marker creation into the first-install mkdir', () => { + const command = makeRelayInstallDirectoryCommand(LINUX, SHELL_RELAY_DIR, namespace) + + expect(command).toContain(SHELL_RELAY_DIR) + expect(command).toContain(`${SHELL_RELAY_DIR}/.install-lock`) + expect(command).toContain('umask 077') + expect(command).toContain(`touch `) + expect(command).toContain(namespace.markerFileName) + }) + + it('is the plain directory command when no namespace applies', () => { + expect(makeRelayInstallDirectoryCommand(WINDOWS, 'C:\\Users\\alice\\relay')).not.toContain( + '.sftp-namespace-' + ) + expect(makeRelayInstallDirectoryCommand(LINUX, SHELL_RELAY_DIR)).not.toContain('touch ') + }) + + it('creates the lock directory before touching the marker inside it', () => { + const command = createRelayInstallMarkerCommand(namespace, LINUX, SHELL_RELAY_DIR) + const markerPath = relayInstallMarkerShellPath(namespace, LINUX, SHELL_RELAY_DIR) + + expect(command.indexOf('.install-lock')).toBeLessThan(command.indexOf('touch ')) + expect(command.indexOf('umask 077')).toBeLessThan(command.indexOf('touch ')) + expect(markerPath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${namespace.markerFileName}`) + expect(command).toContain(markerPath) + }) +}) diff --git a/src/main/ssh/ssh-relay-install-namespace.ts b/src/main/ssh/ssh-relay-install-namespace.ts new file mode 100644 index 00000000000..b46f22dda29 --- /dev/null +++ b/src/main/ssh/ssh-relay-install-namespace.ts @@ -0,0 +1,122 @@ +// Install-owner identity for relay uploads that cross a split shell/SFTP namespace. +// +// The shell and SFTP paths share one validated home-relative suffix +// (`.orca-remote/relay-`); a random marker inside the install lock +// lets each SFTP session prove it is looking at THIS install's directory. +// +// See: docs/ssh-relay-sftp-namespace.md + +import { RELAY_REMOTE_DIR } from './relay-protocol' +import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' +import { shellEscape } from './ssh-connection-utils' +import { RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker' +import { makeRemoteDirectoryCommand } from './ssh-remote-commands' +import { + assertSafeRemotePathSegment, + joinRemotePath, + type RemoteHostPlatform, + type RemotePathFlavor +} from './ssh-remote-platform' + +export type RelayInstallNamespace = { + homeRelativeRelayDir: string + markerFileName: string +} + +/** + * The two validated segments every relay path is built from. Both the shell + * builder and the SFTP-relative builder go through here so they cannot drift. + */ +export function relayRemoteDirSegments( + fullVersion: string, + pathFlavor: RemotePathFlavor +): string[] { + const segments = [RELAY_REMOTE_DIR, `relay-${fullVersion}`] + for (const segment of segments) { + assertSafeRemotePathSegment(segment, pathFlavor) + // Why: the version reaches logs and diagnostics, where an embedded CR/LF can forge lines. + if (segment.includes('\r') || segment.includes('\n')) { + throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`) + } + } + return segments +} + +export function relayHomeRelativeDir(fullVersion: string): string { + return relayRemoteDirSegments(fullVersion, 'posix').join('/') +} + +export function createRelayInstallNamespace(homeRelativeRelayDir: string): RelayInstallNamespace { + // Why: an unguessable token, not just a unique suffix — a same-version directory + // under an unrelated SFTP start directory must not qualify as ours. + return { + homeRelativeRelayDir, + markerFileName: createRelayInstallMarkerFileName() + } +} + +/** + * Describe one relay transfer to the SFTP namespace resolver. `relativeFileName` + * is omitted for the bundle directory upload itself. + */ +export function relaySftpNamespaceMapping( + namespace: RelayInstallNamespace, + host: RemoteHostPlatform, + shellRelayDir: string, + relativeFileName?: string +): SftpNamespacePathMapping { + if (relativeFileName !== undefined) { + assertSafeRemotePathSegment(relativeFileName, 'posix') + if (relativeFileName.includes('\r') || relativeFileName.includes('\n')) { + throw new Error('Unsafe remote path segment in relay SFTP mapping') + } + } + const homeRelativeLockDir = `${namespace.homeRelativeRelayDir}/${RELAY_INSTALL_LOCK_NAME}` + return { + homeRelativePath: + relativeFileName !== undefined + ? `${namespace.homeRelativeRelayDir}/${relativeFileName}` + : namespace.homeRelativeRelayDir, + shellProbePath: relayInstallMarkerShellPath(namespace, host, shellRelayDir), + homeRelativeProbePath: `${homeRelativeLockDir}/${namespace.markerFileName}` + } +} + +export function relayInstallMarkerShellPath( + namespace: RelayInstallNamespace, + host: RemoteHostPlatform, + shellRelayDir: string +): string { + return joinRemotePath(host, shellRelayDir, RELAY_INSTALL_LOCK_NAME, namespace.markerFileName) +} + +/** + * Create the install-owner marker inside the lock this caller already holds. + * POSIX-only: no marker is created for Windows or the system-SSH transport. + */ +export function createRelayInstallMarkerCommand( + namespace: RelayInstallNamespace, + host: RemoteHostPlatform, + shellRelayDir: string +): string { + const lockDir = joinRemotePath(host, shellRelayDir, RELAY_INSTALL_LOCK_NAME) + const markerPath = relayInstallMarkerShellPath(namespace, host, shellRelayDir) + return `${makeRemoteDirectoryCommand(host, lockDir)} && umask 077 && touch ${shellEscape(markerPath)}` +} + +/** + * First-install directory creation, folded together with marker creation so a + * standard install spends no extra exec channel on namespace discovery. + */ +export function makeRelayInstallDirectoryCommand( + host: RemoteHostPlatform, + shellRelayDir: string, + namespace?: RelayInstallNamespace +): string { + const makeDir = makeRemoteDirectoryCommand(host, shellRelayDir) + if (!namespace) { + return makeDir + } + return `${makeDir} && ${createRelayInstallMarkerCommand(namespace, host, shellRelayDir)}` +} diff --git a/src/main/ssh/ssh-relay-install-transfers.ts b/src/main/ssh/ssh-relay-install-transfers.ts new file mode 100644 index 00000000000..decbcd103ee --- /dev/null +++ b/src/main/ssh/ssh-relay-install-transfers.ts @@ -0,0 +1,101 @@ +// Relay-install SFTP writes. Each helper prefers the SshConnection transfer +// method and otherwise drives one SFTP session itself, because deploy and +// native-dependency tests pass partial connection doubles. Both routes share the +// same namespace resolution, abort race, and one-shot session teardown. + +import type { SFTPWrapper } from 'ssh2' +import type { SshConnection } from './ssh-connection' +import { writeStringViaSftp } from './sftp-upload' +import { uploadDirectory } from './ssh-relay-deploy-helpers' +import { raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort' +import { + resolveSftpTransferPathIfMapped, + type SftpNamespacePathMapping +} from './sftp-namespace-resolution' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +export type RelayTransferOptions = { + signal?: AbortSignal + sftpNamespace?: SftpNamespacePathMapping +} + +export async function uploadRelayDirectory( + conn: SshConnection, + localRelayDir: string, + shellRemoteDir: string, + hostPlatform: RemoteHostPlatform, + options?: RelayTransferOptions +): Promise { + if (typeof conn.uploadDirectory === 'function') { + await conn.uploadDirectory(localRelayDir, shellRemoteDir, { + hostPlatform, + signal: options?.signal, + sftpNamespace: options?.sftpNamespace + }) + return + } + await runSftpFallbackTransfer(conn, options, async (sftp) => { + const targetDir = await resolveSftpTransferPathIfMapped(sftp, shellRemoteDir, { + hostPlatform, + sftpNamespace: options?.sftpNamespace + }) + options?.signal?.throwIfAborted() + await uploadDirectory(sftp, localRelayDir, targetDir) + }) +} + +export async function writeRelayFile( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + shellRemotePath: string, + contents: string, + options?: RelayTransferOptions +): Promise { + if (typeof conn.writeFile === 'function') { + await conn.writeFile(shellRemotePath, contents, { + hostPlatform, + signal: options?.signal, + sftpNamespace: options?.sftpNamespace + }) + return + } + await runSftpFallbackTransfer(conn, options, async (sftp) => { + const targetPath = await resolveSftpTransferPathIfMapped(sftp, shellRemotePath, { + hostPlatform, + sftpNamespace: options?.sftpNamespace + }) + options?.signal?.throwIfAborted() + await writeStringViaSftp(sftp, targetPath, contents) + }) +} + +async function runSftpFallbackTransfer( + conn: SshConnection, + options: RelayTransferOptions | undefined, + transfer: (sftp: SFTPWrapper) => Promise +): Promise { + const sftp = await conn.sftp(options?.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + // A late session 'error' after settle would otherwise be unhandled and crash main. + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + await raceSftpFileTransferWithAbort( + transfer(sftp), + options?.signal ?? new AbortController().signal, + (onClose) => { + sftp.once('close', onClose) + endSftp() + } + ) + } finally { + endSftp() + } +} diff --git a/src/main/ssh/ssh-relay-liveness-probe-source.test.ts b/src/main/ssh/ssh-relay-liveness-probe-source.test.ts new file mode 100644 index 00000000000..7c74cf71ef9 --- /dev/null +++ b/src/main/ssh/ssh-relay-liveness-probe-source.test.ts @@ -0,0 +1,156 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { GENERATED_NODE_MANAGED_FILE_MAX_BYTES } from '../generated-node-bounded-file-reader' +import { + getWindowsRelayLivenessProbeSource, + WINDOWS_RELAY_LIVENESS_DIRECTORY_BUFFER_SIZE, + WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE, + WINDOWS_RELAY_LIVENESS_MAX_DIRECTORY_ENTRIES, + WINDOWS_RELAY_LIVENESS_MAX_PIPE_PATHS +} from './ssh-relay-liveness-probe-source' + +const PIPE_A = '\\\\.\\pipe\\orca-relay-00000000000000000000' +const PIPE_B = '\\\\.\\pipe\\orca-relay-11111111111111111111' +const PIPE_C = '\\\\.\\pipe\\orca-relay-22222222222222222222' + +function runProbe( + directory: string, + options: { + maxDirectoryEntries: number + maxPipePaths: number + seedPipePaths?: string[] + } +): string { + const source = getWindowsRelayLivenessProbeSource({ + maxDirectoryEntries: options.maxDirectoryEntries, + maxPipePaths: options.maxPipePaths, + directoryBufferSize: 1 + }) + const result = spawnSync( + process.execPath, + ['-e', source, directory, ...(options.seedPipePaths ?? [])], + { + encoding: 'utf8', + timeout: 5_000 + } + ) + if (result.status !== 0) { + throw new Error(`probe exited ${result.status}: ${result.stderr}`) + } + return result.stdout +} + +function withTempDirectory(run: (directory: string) => void): void { + const directory = mkdtempSync(join(tmpdir(), 'orca-relay-liveness-')) + try { + run(directory) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +describe('Windows relay liveness probe source', () => { + it('uses fixed-capacity incremental directory iteration', () => { + const source = getWindowsRelayLivenessProbeSource() + + expect(source).toContain( + `maxEntries=${WINDOWS_RELAY_LIVENESS_MAX_DIRECTORY_ENTRIES},maxPipes=${WINDOWS_RELAY_LIVENESS_MAX_PIPE_PATHS}` + ) + expect(source).toContain( + `opendirSync(dir,{bufferSize:${WINDOWS_RELAY_LIVENESS_DIRECTORY_BUFFER_SIZE}})` + ) + expect(source).toContain('directory.readSync()') + expect(source).not.toContain('readdirSync') + expect(source).not.toContain('pipes.includes') + }) + + it('allows exactly the directory-entry limit', () => { + withTempDirectory((directory) => { + writeFileSync(join(directory, 'one'), '') + writeFileSync(join(directory, 'two'), '') + + expect( + runProbe(directory, { + maxDirectoryEntries: 2, + maxPipePaths: 1, + seedPipePaths: [PIPE_A] + }) + ).toBe('WAITING') + }) + }) + + it('fails closed when the incremental iterator finds one entry beyond the limit', () => { + withTempDirectory((directory) => { + writeFileSync(join(directory, 'one'), '') + writeFileSync(join(directory, 'two'), '') + writeFileSync(join(directory, 'three'), '') + + expect( + runProbe(directory, { + maxDirectoryEntries: 2, + maxPipePaths: 1, + seedPipePaths: [PIPE_A] + }) + ).toBe(WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE) + }) + }) + + it('allows exactly the retained-pipe limit', () => { + withTempDirectory((directory) => { + writeFileSync(join(directory, '.windows-active-pipe-a'), PIPE_A) + writeFileSync(join(directory, '.windows-active-pipe-b'), PIPE_B) + + expect( + runProbe(directory, { + maxDirectoryEntries: 2, + maxPipePaths: 2 + }) + ).toBe('WAITING') + }) + }) + + it('fails closed before retaining a pipe beyond the limit', () => { + withTempDirectory((directory) => { + writeFileSync(join(directory, '.windows-active-pipe-a'), PIPE_A) + writeFileSync(join(directory, '.windows-active-pipe-b'), PIPE_B) + writeFileSync(join(directory, '.windows-active-pipe-c'), PIPE_C) + + expect( + runProbe(directory, { + maxDirectoryEntries: 3, + maxPipePaths: 2 + }) + ).toBe(WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE) + }) + }) + + it('fails closed when a marker exceeds its file-size limit', () => { + withTempDirectory((directory) => { + writeFileSync( + join(directory, '.windows-active-pipe-oversized'), + 'x'.repeat(GENERATED_NODE_MANAGED_FILE_MAX_BYTES + 1) + ) + + expect( + runProbe(directory, { + maxDirectoryEntries: 1, + maxPipePaths: 1 + }) + ).toBe(WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE) + }) + }) + + it('preserves the missing-directory liveness result', () => { + withTempDirectory((directory) => { + expect( + runProbe(join(directory, 'missing'), { + maxDirectoryEntries: 1, + maxPipePaths: 1 + }) + ).toBe('ALIVE') + }) + }) +}) diff --git a/src/main/ssh/ssh-relay-liveness-probe-source.ts b/src/main/ssh/ssh-relay-liveness-probe-source.ts new file mode 100644 index 00000000000..bc2e8063cc1 --- /dev/null +++ b/src/main/ssh/ssh-relay-liveness-probe-source.ts @@ -0,0 +1,91 @@ +import { getGeneratedNodeBoundedFileReaderSourceLines } from '../generated-node-bounded-file-reader' + +export const WINDOWS_RELAY_LIVENESS_MAX_DIRECTORY_ENTRIES = 4_096 +export const WINDOWS_RELAY_LIVENESS_MAX_PIPE_PATHS = 1_024 +export const WINDOWS_RELAY_LIVENESS_DIRECTORY_BUFFER_SIZE = 32 +export const WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE = 'INCONCLUSIVE' + +type WindowsRelayLivenessProbeSourceOptions = { + maxDirectoryEntries?: number + maxPipePaths?: number + directoryBufferSize?: number +} + +function positiveInteger(value: number | undefined, fallback: number, name: string): number { + const resolved = value ?? fallback + if (!Number.isSafeInteger(resolved) || resolved < 1) { + throw new Error(`${name} must be a positive integer`) + } + return resolved +} + +export function getWindowsRelayLivenessProbeSource( + options?: WindowsRelayLivenessProbeSourceOptions +): string { + const maxDirectoryEntries = positiveInteger( + options?.maxDirectoryEntries, + WINDOWS_RELAY_LIVENESS_MAX_DIRECTORY_ENTRIES, + 'maxDirectoryEntries' + ) + const maxPipePaths = positiveInteger( + options?.maxPipePaths, + WINDOWS_RELAY_LIVENESS_MAX_PIPE_PATHS, + 'maxPipePaths' + ) + const directoryBufferSize = positiveInteger( + options?.directoryBufferSize, + WINDOWS_RELAY_LIVENESS_DIRECTORY_BUFFER_SIZE, + 'directoryBufferSize' + ) + const boundedReadSource = getGeneratedNodeBoundedFileReaderSourceLines().join('') + + return [ + 'const fs=require("fs"),path=require("path"),net=require("net");', + boundedReadSource, + 'const [dir,...seed]=process.argv.slice(1);', + 'const valid=/^\\\\\\\\[.?]\\\\pipe\\\\orca-relay-[0-9a-f]{20}$/i;', + `const maxEntries=${maxDirectoryEntries},maxPipes=${maxPipePaths};`, + 'const pipes=[],seen=new Set();', + 'let markerCount=0,capacityExceeded=false;', + 'function addPipe(p){', + 'if(!valid.test(p)||seen.has(p))return;', + 'if(pipes.length>=maxPipes){capacityExceeded=true;return}', + 'seen.add(p);pipes.push(p)', + '}', + 'for(const p of seed){addPipe(p);if(capacityExceeded)break}', + 'let directory;', + 'if(!capacityExceeded)try{', + `directory=fs.opendirSync(dir,{bufferSize:${directoryBufferSize}});`, + 'let entryCount=0;', + 'while(true){', + 'const entry=directory.readSync();', + 'if(!entry)break;', + 'if(entryCount>=maxEntries){capacityExceeded=true;break}', + 'entryCount++;', + 'const name=entry.name;', + 'if(!name.startsWith(".windows-active-pipe-"))continue;', + 'markerCount++;', + 'const p=readOrcaManagedFileWithinLimit(fs,path.join(dir,name)).trim();', + 'addPipe(p);', + 'if(capacityExceeded)break', + '}', + '}catch(error){if(error&&error.code==="EFBIG")capacityExceeded=true}', + 'finally{if(directory)try{directory.closeSync()}catch{}}', + `if(capacityExceeded){process.stdout.write("${WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE}");process.exit(0)}`, + 'if(markerCount===0&&pipes.length===0){process.stdout.write("ALIVE");process.exit(0)}', + 'let i=0;', + 'function done(ok){process.stdout.write(ok?"ALIVE":"WAITING")}', + 'function next(){', + 'const pipe=pipes[i++];', + 'if(!pipe)return done(false);', + 'const s=net.connect(pipe);', + 'let settled=false;', + 'function finish(ok){if(settled)return;settled=true;s.destroy();if(ok)done(true);else next()}', + 's.setTimeout(200);', + 's.on("connect",()=>finish(true));', + 's.on("timeout",()=>finish(false));', + 's.on("error",()=>finish(false));', + '}', + 'next();' + ].join('') +} diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index fdc7ff3e73e..ceab584dfed 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,5 +1,6 @@ // Why: regression coverage for the install-probe contract — the "node-pty is not available" bug shipped because every guard layer was silent. +import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -46,7 +47,8 @@ vi.mock('./ssh-relay-versioned-install', () => ({ })) vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined) + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' })) vi.mock('./ssh-relay-repair-lock', () => ({ @@ -84,37 +86,32 @@ type SftpWriteCapture = { execCallCountAtWrite: Record } +type SftpCallback = (err: Error | null, resolved?: string) => void +const NO_SUCH_SFTP_FILE = Object.assign(new Error('No such file'), { code: 2 }) + function makeMockConnection(capture: SftpWriteCapture): SshConnection { - const sftpCreate = (): unknown => ({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - on: vi.fn(), - once: vi.fn(), - createWriteStream: vi.fn().mockImplementation((path: string) => { - capture.paths.push(path) - let buf = '' - let closeCb: (() => void) | undefined - const stub = { - on: vi.fn((event: string, cb: () => void) => { - if (event === 'close') { - closeCb = cb - } - }), - end: vi.fn((data?: string) => { - if (typeof data === 'string') { - buf += data - } - capture.contents[path] = buf - capture.execCallCountAtWrite[path] = vi.mocked(execCommand).mock.calls.length - if (closeCb) { - setTimeout(closeCb, 0) - } + // Why: production attaches/removes real listeners (including prependOnceListener), so the fake must be an emitter. + const sftpCreate = (): unknown => { + const sftp = new EventEmitter() + return Object.assign(sftp, { + mkdir: vi.fn((_p: string, cb: SftpCallback) => cb(null)), + // This host's shell home and SFTP start directory agree, so no namespace redirect is possible. + realpath: vi.fn((_p: string, cb: SftpCallback) => cb(null, '/home/u')), + lstat: vi.fn((_p: string, cb: SftpCallback) => cb(NO_SUCH_SFTP_FILE)), + createWriteStream: vi.fn().mockImplementation((path: string) => { + capture.paths.push(path) + const ws = new EventEmitter() + return Object.assign(ws, { + end: vi.fn((data?: string) => { + capture.contents[path] = `${capture.contents[path] ?? ''}${data ?? ''}` + capture.execCallCountAtWrite[path] = vi.mocked(execCommand).mock.calls.length + setTimeout(() => ws.emit('close'), 0) + }) }) - } - // Why: production uses ws.once('close'); the mock delegates 'once' to the same handler table as 'on'. - return Object.assign(stub, { once: stub.on }) - }), - end: vi.fn() - }) + }), + end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0)) + }) + } return { canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), exec: vi.fn().mockResolvedValue({ @@ -694,6 +691,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { '/home/u', 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // first probe before lock 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // re-probe after lock + '', // SFTP-namespace install-owner marker (repair) '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', @@ -730,6 +728,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { '/home/u', 'MISSING', // health probe: require() fails 'MISSING', // re-probe after lock + '', // SFTP-namespace install-owner marker (repair) { reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline) 'DEAD', 'READY' @@ -752,6 +751,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { .mockResolvedValueOnce('/home/u') .mockResolvedValueOnce('MISSING') .mockResolvedValueOnce('MISSING') + .mockResolvedValueOnce('') // SFTP-namespace install-owner marker (repair) .mockRejectedValueOnce( Object.assign(new Error('npm termination was not confirmed'), { sshChannelCloseConfirmed: false diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts index 901f7940d31..61283ffae0e 100644 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts @@ -56,6 +56,12 @@ vi.mock('../ipc/pty', () => ({ setPtyOwnership: vi.fn() })) +vi.mock('../ipc/pty-renderer-delivery-router', () => ({ + routeExternalPtyData: vi.fn(), + routeExternalPtyReplay: vi.fn(), + routeExternalPtyExit: vi.fn() +})) + vi.mock('../providers/ssh-filesystem-dispatch', () => ({ registerSshFilesystemProvider: vi.fn(), unregisterSshFilesystemProvider: vi.fn(), @@ -68,6 +74,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const { registerSshPtyProvider } = await import('../ipc/pty') +const { routeExternalPtyData } = await import('../ipc/pty-renderer-delivery-router') describe('SshRelaySession data delivery', () => { beforeEach(() => { @@ -76,16 +83,9 @@ describe('SshRelaySession data delivery', () => { mockDeploySuccess() }) - it('delivers empty transformed relay spans with raw sequence metadata', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const runtime = { onPtyData: vi.fn(() => 17), onPtyExit: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) + it('routes empty transformed relay spans with raw sequence metadata', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { onData: ReturnType @@ -99,14 +99,11 @@ describe('SshRelaySession data delivery', () => { onData({ id: 'ssh-pty-1', data: '', sequenceChars: 9, transformed: true }) - expect(runtime.onPtyData).toHaveBeenCalledWith('ssh-pty-1', '', expect.any(Number), 9, true) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + expect(routeExternalPtyData).toHaveBeenCalledWith({ id: 'ssh-pty-1', data: '', sequenceChars: 9, - transformed: true, - seq: 17, - rawLength: 9 + transformed: true }) }) }) diff --git a/src/main/ssh/ssh-relay-session-incarnation.test.ts b/src/main/ssh/ssh-relay-session-incarnation.test.ts index a98180308b2..828de0fcef4 100644 --- a/src/main/ssh/ssh-relay-session-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-incarnation.test.ts @@ -51,6 +51,11 @@ vi.mock('../ipc/pty', () => ({ isCurrentPtyExit: vi.fn(() => true), answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) })) +vi.mock('../ipc/pty-renderer-delivery-router', () => ({ + routeExternalPtyData: vi.fn(), + routeExternalPtyReplay: vi.fn(), + routeExternalPtyExit: vi.fn() +})) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ registerSshFilesystemProvider: vi.fn(), unregisterSshFilesystemProvider: vi.fn(), @@ -63,6 +68,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ const { registerSshPtyProvider, clearProviderPtyState, deletePtyOwnership, isCurrentPtyExit } = await import('../ipc/pty') +const { routeExternalPtyExit } = await import('../ipc/pty-renderer-delivery-router') describe('SSH relay PTY incarnation exits', () => { beforeEach(() => { @@ -73,7 +79,7 @@ describe('SSH relay PTY incarnation exits', () => { }) it('drops a stale exit before ownership cleanup and propagates a current incarnation', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() } const session = new SshRelaySession( 'target-1', @@ -99,13 +105,19 @@ describe('SSH relay PTY incarnation exits', () => { expect(deletePtyOwnership).not.toHaveBeenCalled() expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) + expect(routeExternalPtyExit).not.toHaveBeenCalled() - onExit({ id: 'ssh:target-1@@pty-reused', code: 7, incarnationId: 'current-incarnation' }) + const currentExit = { + id: 'ssh:target-1@@pty-reused', + code: 7, + incarnationId: 'current-incarnation' + } + onExit(currentExit) expect(runtime.onPtyExit).toHaveBeenCalledWith( 'ssh:target-1@@pty-reused', 7, 'current-incarnation' ) + expect(routeExternalPtyExit).toHaveBeenCalledWith(currentExit) }) }) diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index 1a3e8721064..ce89762c602 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -53,6 +53,11 @@ vi.mock('../ipc/pty', () => ({ isCurrentPtyExit: vi.fn(() => true), answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) })) +vi.mock('../ipc/pty-renderer-delivery-router', () => ({ + routeExternalPtyData: vi.fn(), + routeExternalPtyReplay: vi.fn(), + routeExternalPtyExit: vi.fn() +})) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ registerSshFilesystemProvider: vi.fn(), unregisterSshFilesystemProvider: vi.fn(), @@ -70,6 +75,7 @@ const { setPtyOwnership, restorePtyIncarnation } = await import('../ipc/pty') +const { routeExternalPtyReplay } = await import('../ipc/pty-renderer-delivery-router') const APP_PTY_ID = 'ssh:target-1@@pty-live' const INCARNATION_LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -146,7 +152,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { }) it('does not restore a PTY whose matching exit shares the attach reply batch', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const incarnationId = 'incarnation-exited-during-attach' const runtime = { acceptPtyIncarnationForExit: vi.fn(), @@ -186,15 +192,11 @@ describe('SshRelaySession reconnect incarnation ordering', () => { 'pty-live', 'terminated' ) - expect( - vi - .mocked(mockWindow.webContents.send) - .mock.calls.some(([channel]) => channel === 'pty:replay') - ).toBe(false) + expect(routeExternalPtyReplay).not.toHaveBeenCalled() }) it('ignores an older incarnation exit while reconnecting a reused PTY id', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const currentIncarnationId = 'incarnation-current' const runtime = { acceptPtyIncarnationForExit: vi.fn(), @@ -237,7 +239,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { expect(mockStore.persistPtyBinding).toHaveBeenCalledWith( expect.objectContaining({ ptyId: APP_PTY_ID, incarnationId: currentIncarnationId }) ) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { + expect(routeExternalPtyReplay).toHaveBeenCalledWith({ id: APP_PTY_ID, data: 'live-output' }) diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 8fe7a292bcf..fc055643fc6 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -74,6 +74,12 @@ vi.mock('../ipc/pty', () => ({ isCurrentPtyExit: vi.fn(() => true) })) +vi.mock('../ipc/pty-renderer-delivery-router', () => ({ + routeExternalPtyData: vi.fn(), + routeExternalPtyReplay: vi.fn(), + routeExternalPtyExit: vi.fn() +})) + vi.mock('../providers/ssh-filesystem-dispatch', () => ({ registerSshFilesystemProvider: vi.fn(), unregisterSshFilesystemProvider: vi.fn(), @@ -86,12 +92,6 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') -// Why: the hidden-delivery gate module is intentionally real (pure state, no -// electron deps) so the SSH parity tests exercise the same gate main uses. -const { markHiddenRendererPty, setRendererPtyDeliveryInterest } = - await import('../ipc/pty-hidden-delivery-gate') -const { _resetHiddenRendererPtyDeliveryGateForTest } = - await import('../ipc/pty-hidden-delivery-gate') const { execCommand } = await import('./ssh-relay-deploy-helpers') const { getRemoteHostPlatform } = await import('./ssh-remote-platform') const { @@ -106,6 +106,8 @@ const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = await import('../providers/ssh-filesystem-dispatch') const { registerSshGitProvider, unregisterSshGitProvider } = await import('../providers/ssh-git-dispatch') +const { routeExternalPtyData, routeExternalPtyReplay, routeExternalPtyExit } = + await import('../ipc/pty-renderer-delivery-router') describe('SshRelaySession', () => { beforeEach(() => { @@ -115,80 +117,10 @@ describe('SshRelaySession', () => { muxRequestMock.mockResolvedValue([]) mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - _resetHiddenRendererPtyDeliveryGateForTest() }) - it('drops hidden-gated PTY data after runtime ingestion with one restore marker', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const runtime = { - onPtyData: vi.fn(() => 99), - onPtyExit: vi.fn() - } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { - id: string - data: string - }) => void - - markHiddenRendererPty('ssh-pty-1') - onData({ id: 'ssh-pty-1', data: 'hidden ssh output' }) - - // Runtime ingestion still ran; renderer delivery shrank to one marker. - expect(runtime.onPtyData).toHaveBeenCalledWith( - 'ssh-pty-1', - 'hidden ssh output', - expect.any(Number), - 'hidden ssh output'.length, - undefined - ) - expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) - // Why out-of-band: an in-band empty pty:data sentinel is ambiguous with - // chunks fully consumed by renderer OSC-9999 stripping. - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { - id: 'ssh-pty-1', - reason: 'hidden-drop', - markerSeq: 99 - }) - - onData({ id: 'ssh-pty-1', data: 'more hidden ssh output' }) - expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) - - // Delivery interest (renderer sidecars) suppresses the gate — parity with - // the local path in ipc/pty.ts. - setRendererPtyDeliveryInterest('ssh-pty-1', true) - onData({ id: 'ssh-pty-1', data: 'sidecar ssh bytes' }) - expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { - id: 'ssh-pty-1', - data: 'sidecar ssh bytes', - seq: 99, - rawLength: 'sidecar ssh bytes'.length - }) - - // Non-hidden PTYs are unaffected. - onData({ id: 'ssh-pty-2', data: 'visible ssh output' }) - expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { - id: 'ssh-pty-2', - data: 'visible ssh output', - seq: 99, - rawLength: 'visible ssh output'.length - }) - }) - - it('keeps hidden SSH delivery when the gate kill switch is off', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - ;(mockStore as unknown as { getSettings: () => unknown }).getSettings = vi.fn(() => ({ - terminalHiddenDeliveryGate: false - })) + it('routes SSH PTY data and its captured producer credit through bounded delivery', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { @@ -197,15 +129,16 @@ describe('SshRelaySession', () => { const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { id: string data: string + upstreamCredit?: { charCount: number; acknowledge(chars: number): void } }) => void + const upstreamCredit = { charCount: 16, acknowledge: vi.fn() } - markHiddenRendererPty('ssh-pty-1') - onData({ id: 'ssh-pty-1', data: 'still delivered' }) + onData({ id: 'ssh-pty-1', data: 'remote PTY bytes', upstreamCredit }) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + expect(routeExternalPtyData).toHaveBeenCalledWith({ id: 'ssh-pty-1', - data: 'still delivered', - rawLength: 'still delivered'.length + data: 'remote PTY bytes', + upstreamCredit }) }) @@ -479,7 +412,7 @@ describe('SshRelaySession', () => { }) it('forwards reconnect replay after the attach attempt is still current', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) vi.clearAllMocks() @@ -495,14 +428,14 @@ describe('SshRelaySession', () => { await session.reconnect(mockConn) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { + expect(routeExternalPtyReplay).toHaveBeenCalledWith({ id: 'ssh:target-1@@pty-1', data: 'restored-output' }) }) it('drops identical reconnect replay payloads inside one reconnect burst', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) vi.clearAllMocks() @@ -519,11 +452,8 @@ describe('SshRelaySession', () => { await session.reconnect(mockConn) await session.reconnect(mockConn) - const replaySends = vi - .mocked(mockWindow.webContents.send) - .mock.calls.filter(([channel]) => channel === 'pty:replay') expect(mockAttach).toHaveBeenCalledTimes(2) - expect(replaySends).toHaveLength(1) + expect(routeExternalPtyReplay).toHaveBeenCalledTimes(1) }) it('establish re-attaches owned PTYs after explicit disconnect', async () => { @@ -612,7 +542,7 @@ describe('SshRelaySession', () => { }) it('does not expire a live reused relay id when attach rejects identity mismatch', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) vi.clearAllMocks() @@ -647,7 +577,7 @@ describe('SshRelaySession', () => { expect(clearProviderPtyState).not.toHaveBeenCalledWith('ssh:target-1@@pty-1') expect(deletePtyOwnership).not.toHaveBeenCalledWith('ssh:target-1@@pty-1') expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('target-1', 'pty-1', 'expired') - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', { + expect(routeExternalPtyExit).not.toHaveBeenCalledWith({ id: 'ssh:target-1@@pty-1', code: -1 }) @@ -718,7 +648,7 @@ describe('SshRelaySession', () => { }) it('invalidates and broadcasts remote PTYs that cannot reattach after relay reconnect', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) vi.clearAllMocks() @@ -741,7 +671,7 @@ describe('SshRelaySession', () => { expect(mockAttach).toHaveBeenCalledWith('pty-live') expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:target-1@@pty-stale') expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-stale') - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + expect(routeExternalPtyExit).toHaveBeenCalledWith({ id: 'ssh:target-1@@pty-stale', code: -1 }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index fdd0b115203..c3070ae4c1a 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -37,10 +37,10 @@ import { isCurrentPtyExit } from '../ipc/pty' import { - recordHiddenRendererPtyDataDrop, - shouldDropHiddenRendererPtyData -} from '../ipc/pty-hidden-delivery-gate' -import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' + routeExternalPtyData, + routeExternalPtyExit, + routeExternalPtyReplay +} from '../ipc/pty-renderer-delivery-router' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider, @@ -919,45 +919,10 @@ export class SshRelaySession { private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { ptyProvider.onData((payload) => { - const rawLength = payload.sequenceChars ?? payload.data.length - const seq = this.runtime?.onPtyData( - payload.id, - payload.data, - Date.now(), - rawLength, - payload.transformed - ) - const win = this.getMainWindow() - if (!win || win.isDestroyed()) { - return - } - // Why: hidden-delivery gate parity with ipc/pty.ts — latch model-restore out-of-band, never an in-band pty:data sentinel (OSC-9999-only chunks strip to empty). - const store = this.store as { getSettings?: Store['getSettings'] } - if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) { - const drop = recordHiddenRendererPtyDataDrop(payload.id, rawLength) - if (drop.shouldEmitRestoreMarker) { - win.webContents.send('pty:modelRestoreNeeded', { - id: payload.id, - reason: 'hidden-drop', - ...(typeof seq === 'number' ? { markerSeq: seq } : {}) - } satisfies PtyModelRestoreNeededEvent) - } - return - } - if (payload.data.length > 0 || payload.transformed) { - win.webContents.send('pty:data', { - ...payload, - ...(typeof seq === 'number' ? { seq } : {}), - rawLength, - ...(payload.transformed ? { transformed: true } : {}) - }) - } + routeExternalPtyData(payload) }) ptyProvider.onReplay((payload) => { - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:replay', payload) - } + routeExternalPtyReplay(payload) }) ptyProvider.onExit((payload) => { const pendingReattach = this.pendingPtyReattaches.get(payload.id) @@ -980,10 +945,7 @@ export class SshRelaySession { this.forwardedReattachReplayByPty.delete(payload.id) this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId) - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:exit', payload) - } + routeExternalPtyExit(payload) } private replayFingerprint(data: string): string { @@ -1008,10 +970,7 @@ export class SshRelaySession { if (!data || !this.shouldForwardReattachReplay(appPtyId, data)) { return } - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:replay', { id: appPtyId, data }) - } + routeExternalPtyReplay({ id: appPtyId, data }) } private async reattachKnownPtys(shouldContinue: () => boolean): Promise { @@ -1125,10 +1084,7 @@ export class SshRelaySession { this.forwardedReattachReplayByPty.delete(appPtyId) this.store.markSshRemotePtyLease(this.targetId, ptyId, 'expired') // Why: reattach failure means the remote process is gone; tell the renderer to clear the stale pane. - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:exit', { id: appPtyId, code: -1 }) - } + routeExternalPtyExit({ id: appPtyId, code: -1 }) } finally { if (this.pendingPtyReattaches.get(appPtyId) === pendingReattach) { this.pendingPtyReattaches.delete(appPtyId) diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts new file mode 100644 index 00000000000..a9e33e62ca1 --- /dev/null +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -0,0 +1,599 @@ +// Why: on a split-namespace host (Synology DSM) the shell path and the SFTP path +// name the same directory differently, so the deploy must keep issuing shell +// commands against the canonical path while every SFTP write is redirected — and +// only when this connection's own install marker proves the candidate is ours. + +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'`, + createSshOperationAbortError: () => + Object.assign(new Error('SSH operation was cancelled'), { + name: 'AbortError' + }) +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { + abandonInstall, + finalizeInstall, + isRelayAlreadyInstalled +} from './ssh-relay-versioned-install' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import type { SshConnection } from './ssh-connection' +import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' + +// The transfer-option slice these tests inspect; SshConnection keeps its own options type internal. +type TransferOptions = { sftpNamespace?: SftpNamespacePathMapping } + +const SHELL_HOME = '/home/u' +const SFTP_HOME = '/homes/u' +const RELAY_SUFFIX = '.orca-remote/relay-0.1.0+testhash' +const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_SUFFIX}` +const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_SUFFIX}` +const MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/ + +type ConnectionOptions = { + // '/homes/u' models a DSM host whose SFTP subsystem starts outside the shell home. + sftpStartPath?: string + lstatPresent?: (path: string) => boolean + hangRealpath?: boolean + // Models an SFTP session that never confirms close, so teardown stays unconfirmed. + neverCloses?: boolean + systemSsh?: boolean + // Present only on the shipping path; absent doubles exercise the deploy's own SFTP fallback. + transferMethods?: boolean +} + +type Capture = { + writePaths: string[] + uploadTargets: string[] + realpathCalls: string[] + lstatCalls: string[] + sftpEndCalls: number + uploadOptions: (TransferOptions | undefined)[] + writeOptions: (TransferOptions | undefined)[] +} + +function newCapture(): Capture { + return { + writePaths: [], + uploadTargets: [], + realpathCalls: [], + lstatCalls: [], + sftpEndCalls: 0, + uploadOptions: [], + writeOptions: [] + } +} + +// The marker this run created, read back from the shell command that made it. +function issuedMarkerName(): string | undefined { + for (const [, command] of vi.mocked(execCommand).mock.calls) { + const match = decodeCommand(command).match(MARKER_PATTERN) + if (match) { + return match[0] + } + } + return undefined +} + +function decodeCommand(command: string): string { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + return match ? Buffer.from(match[1], 'base64').toString('utf16le') : command +} + +function execCommands(): string[] { + return vi.mocked(execCommand).mock.calls.map(([, command]) => decodeCommand(command)) +} + +function makeConnection(capture: Capture, options: ConnectionOptions = {}): SshConnection { + const startPath = options.sftpStartPath ?? SFTP_HOME + // Default: the marker is visible only through the SFTP namespace, and only under this install's token. + const lstatPresent = + options.lstatPresent ?? + ((path: string) => + path.startsWith(`${SFTP_HOME}/`) && path.includes(issuedMarkerName() ?? '\0')) + + const makeSftp = (): unknown => { + const sftp = new EventEmitter() + return Object.assign(sftp, { + mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), + realpath: vi.fn((path: string, cb: (err: Error | null, resolved?: string) => void) => { + capture.realpathCalls.push(path) + if (options.hangRealpath) { + return + } + cb(null, startPath) + }), + lstat: vi.fn((path: string, cb: (err: Error | null) => void) => { + capture.lstatCalls.push(path) + cb(lstatPresent(path) ? null : Object.assign(new Error('No such file'), { code: 2 })) + }), + createWriteStream: vi.fn().mockImplementation((path: string) => { + capture.writePaths.push(path) + const ws = new EventEmitter() + return Object.assign(ws, { + end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)) + }) + }), + end: vi.fn(() => { + capture.sftpEndCalls += 1 + if (!options.neverCloses) { + setTimeout(() => sftp.emit('close'), 0) + } + }) + }) + } + + const conn: Record = { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), + exec: vi.fn().mockResolvedValue({ + on: vi.fn(), + stderr: { on: vi.fn() }, + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + }), + sftp: vi.fn().mockImplementation(() => Promise.resolve(makeSftp())) + } + if (options.systemSsh) { + conn.usesSystemSshTransport = vi.fn().mockReturnValue(true) + } + if (options.transferMethods) { + conn.uploadDirectory = vi + .fn() + .mockImplementation((_local: string, remote: string, opts?: TransferOptions) => { + capture.uploadTargets.push(remote) + capture.uploadOptions.push(opts) + return Promise.resolve() + }) + conn.writeFile = vi + .fn() + .mockImplementation((remote: string, _contents: string, opts?: TransferOptions) => { + capture.writePaths.push(remote) + capture.writeOptions.push(opts) + return Promise.resolve() + }) + } + return conn as unknown as SshConnection +} + +function feed(responses: string[]): void { + for (const response of responses) { + vi.mocked(execCommand).mockResolvedValueOnce(response) + } +} + +// POSIX first install, healthy npm install and node-pty probe. +const POSIX_FIRST_INSTALL = [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + '', // mkdir remoteDir (+ install-owner marker) + '', // chmod +x node + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + 'READY' +] + +// POSIX repair of an installed dir whose native deps are missing. +const POSIX_REPAIR = [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + 'MISSING', // probe before the repair lock + 'MISSING', // re-probe under the lock + '', // install-owner marker + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + 'READY' +] + +describe('relay install writes on a split SFTP namespace', () => { + let capture: Capture + let warnSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset() + vi.mocked(uploadDirectory).mockImplementation((_sftp, _local, remote: string) => { + capture.uploadTargets.push(remote) + return Promise.resolve() + }) + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') + capture = newCapture() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + vi.restoreAllMocks() + }) + + it('redirects every first-install write while shell commands keep the canonical path', async () => { + const conn = makeConnection(capture) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + expect(capture.uploadTargets).toEqual([SFTP_RELAY_DIR]) + expect(capture.writePaths).toEqual([ + `${SFTP_RELAY_DIR}/.version`, + `${SFTP_RELAY_DIR}/package.json` + ]) + // Every shell command — mkdir, chmod, npm, launch — still names the shell path. + for (const command of execCommands()) { + expect(command).not.toContain(SFTP_RELAY_DIR) + } + expect(execCommands().some((command) => command.includes(SHELL_RELAY_DIR))).toBe(true) + }) + + it('folds the install-owner marker into the first-install mkdir', async () => { + const conn = makeConnection(capture) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + const markerCommands = execCommands().filter((command) => MARKER_PATTERN.test(command)) + expect(markerCommands).toHaveLength(1) + expect(markerCommands[0]).toContain('mkdir') + expect(markerCommands[0]).toContain(`${SHELL_RELAY_DIR}/.install-lock`) + }) + + it('probes one shared marker for every write of an install', async () => { + const conn = makeConnection(capture) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + const marker = issuedMarkerName() + expect(marker).toMatch(MARKER_PATTERN) + expect(capture.lstatCalls).toEqual([ + `${SHELL_RELAY_DIR}/.install-lock/${marker}`, + `${SFTP_RELAY_DIR}/.install-lock/${marker}`, + `${SHELL_RELAY_DIR}/.install-lock/${marker}`, + `${SFTP_RELAY_DIR}/.install-lock/${marker}`, + `${SHELL_RELAY_DIR}/.install-lock/${marker}`, + `${SFTP_RELAY_DIR}/.install-lock/${marker}` + ]) + }) + + it('refuses a same-version candidate dir that carries another install marker', async () => { + const foreign = `.sftp-namespace-${'f'.repeat(32)}` + const conn = makeConnection(capture, { + lstatPresent: (path) => path.startsWith(`${SFTP_HOME}/`) && path.includes(foreign) + }) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.writePaths).toEqual([ + `${SHELL_RELAY_DIR}/.version`, + `${SHELL_RELAY_DIR}/package.json` + ]) + }) + + it('keeps the shell path and skips probing when both namespaces agree', async () => { + const conn = makeConnection(capture, { sftpStartPath: SHELL_HOME }) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.lstatCalls).toEqual([]) + }) + + it('resolves against a start directory outside any home', async () => { + const conn = makeConnection(capture, { + sftpStartPath: '/volume1/shared', + lstatPresent: (path) => path.startsWith('/volume1/shared/') + }) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + expect(capture.uploadTargets).toEqual([`/volume1/shared/${RELAY_SUFFIX}`]) + }) + + it('passes the same mapping to a connection that owns its own transfer methods', async () => { + const conn = makeConnection(capture, { transferMethods: true }) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + // The shipping path hands over shell paths plus a mapping; resolution happens on the write session. + expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.writePaths).toEqual([ + `${SHELL_RELAY_DIR}/.version`, + `${SHELL_RELAY_DIR}/package.json` + ]) + const marker = issuedMarkerName() + const mappings = [...capture.uploadOptions, ...capture.writeOptions].map( + (options) => options?.sftpNamespace + ) + expect(mappings).toHaveLength(3) + for (const mapping of mappings) { + expect(mapping?.shellProbePath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${marker}`) + expect(mapping?.homeRelativeProbePath).toBe(`${RELAY_SUFFIX}/.install-lock/${marker}`) + } + expect(mappings.map((mapping) => mapping?.homeRelativePath)).toEqual([ + RELAY_SUFFIX, + `${RELAY_SUFFIX}/.version`, + `${RELAY_SUFFIX}/package.json` + ]) + expect(capture.realpathCalls).toEqual([]) + }) + + it('leaves system-SSH connections unmapped and unprobed', async () => { + // transferMethods models the shipping SshConnection path (uploadDirectory/writeFile → system SSH helpers). + const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) + expect(capture.realpathCalls).toEqual([]) + expect(capture.lstatCalls).toEqual([]) + expect(conn.sftp).not.toHaveBeenCalled() + // System SSH never retargets: shell absolute paths, no mapping, no SFTP session. + expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.writePaths).toEqual([ + `${SHELL_RELAY_DIR}/.version`, + `${SHELL_RELAY_DIR}/package.json` + ]) + const transferOptions = [...capture.uploadOptions, ...capture.writeOptions] + expect(transferOptions).toHaveLength(3) + for (const options of transferOptions) { + expect(options?.sftpNamespace).toBeUndefined() + } + }) + + it('leaves Windows hosts unmapped and unprobed', async () => { + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('win32-x64') + const conn = makeConnection(capture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Windows AMD64', + 'C:\\Users\\u', + '' // mkdir remoteDir + ]) + // Fail the install right after the package.json write; the launch path is not what this asserts. + vi.mocked(execCommand).mockRejectedValueOnce(new Error('npm install failed')) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow('npm install failed') + + expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) + expect(capture.realpathCalls).toEqual([]) + expect(capture.writePaths).toEqual([ + 'C:/Users/u/.orca-remote/relay-0.1.0+testhash/.version', + 'C:/Users/u/.orca-remote/relay-0.1.0+testhash/package.json' + ]) + }) + + it('releases the first-install lock when a redirected upload fails', async () => { + const conn = makeConnection(capture) + feed(POSIX_FIRST_INSTALL) + vi.mocked(uploadDirectory).mockRejectedValueOnce(new Error('sftp write failed')) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow('sftp write failed') + + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + }) + + it('ends the SFTP session once when a deploy abort strands namespace discovery', async () => { + vi.useFakeTimers() + try { + const conn = makeConnection(capture, { hangRealpath: true }) + feed(POSIX_FIRST_INSTALL) + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1)) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + const result = await deploy + + expect((result as Error).message).toContain('Relay deployment timed out') + await vi.advanceTimersByTimeAsync(5_000) + expect(capture.sftpEndCalls).toBe(1) + // A confirmed close releases the first-install lock and leaves the dir incomplete. + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('retains the first-install lock when the aborted SFTP session never closes', async () => { + vi.useFakeTimers() + try { + const conn = makeConnection(capture, { hangRealpath: true, neverCloses: true }) + feed(POSIX_FIRST_INSTALL) + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1)) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + await deploy + await vi.advanceTimersByTimeAsync(5_000) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) + +describe('relay repair writes on a split SFTP namespace', () => { + let capture: Capture + let warnSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset() + vi.mocked(uploadDirectory).mockResolvedValue(undefined) + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') + capture = newCapture() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + vi.restoreAllMocks() + }) + + it('stamps the marker only after the locked recheck, then redirects package.json', async () => { + const conn = makeConnection(capture) + let execCountAtLock = -1 + vi.mocked(tryAcquireRelayRepairLock).mockImplementation(() => { + execCountAtLock = vi.mocked(execCommand).mock.calls.length + return Promise.resolve('acquired') + }) + feed(POSIX_REPAIR) + + await deployAndLaunchRelay(conn) + + const commands = execCommands() + const markerIndex = commands.findIndex((command) => MARKER_PATTERN.test(command)) + expect(markerIndex).toBeGreaterThan(execCountAtLock) + // The re-probe under the lock is the last exec before the marker. + expect(commands[markerIndex - 1]).toContain('loadNativeModule') + expect(capture.writePaths).toEqual([`${SFTP_RELAY_DIR}/package.json`]) + }) + + it('does not stamp a marker when repairing over system SSH', async () => { + const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + 'MISSING', + 'MISSING', + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + 'READY' + ]) + + await deployAndLaunchRelay(conn) + + expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) + expect(conn.sftp).not.toHaveBeenCalled() + expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`]) + expect(capture.writeOptions).toEqual([expect.objectContaining({ sftpNamespace: undefined })]) + }) + + it('degrades to shell paths when marker creation fails outright', async () => { + const conn = makeConnection(capture) + feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING']) + vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only file system')) + feed([ + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + 'READY' + ]) + + await deployAndLaunchRelay(conn) + + expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`]) + expect(capture.realpathCalls).toEqual([]) + expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual( + expect.stringContaining('SFTP namespace marker unavailable') + ) + }) + + it('keeps the repair lock when marker creation has unconfirmed termination', async () => { + const conn = makeConnection(capture) + feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING']) + vi.mocked(execCommand).mockRejectedValueOnce( + Object.assign(new Error('marker teardown unconfirmed'), { sshChannelCloseConfirmed: false }) + ) + feed(['DEAD', 'READY']) + + await deployAndLaunchRelay(conn) + + // Repair is best-effort: the relay still launches, but nothing was written and no lock was released. + expect(capture.writePaths).toEqual([]) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual( + expect.stringContaining('launching degraded') + ) + }) +}) diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 23d57757b5b..1b6390879a8 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -1,8 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('fs', () => ({ - existsSync: vi.fn(), - readFileSync: vi.fn() + existsSync: vi.fn() +})) + +const readRelayVersionMarkerSyncMock = vi.hoisted(() => vi.fn()) +vi.mock('../../shared/relay-version-marker', () => ({ + readRelayVersionMarkerSync: readRelayVersionMarkerSyncMock })) vi.mock('./ssh-relay-deploy-helpers', () => ({ @@ -13,7 +17,7 @@ vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { readLocalFullVersion, computeRemoteRelayDir, @@ -34,11 +38,11 @@ import { import { execCommand } from './ssh-relay-deploy-helpers' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' +import { RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL } from './ssh-relay-base-directory-listing' const conn = {} as SshConnection const mockExec = vi.mocked(execCommand) const mockExists = vi.mocked(existsSync) -const mockRead = vi.mocked(readFileSync) function decodePowerShellCommand(command: string): string { const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) @@ -52,7 +56,7 @@ describe('readLocalFullVersion', () => { it('returns trimmed contents of the .version file', () => { mockExists.mockReturnValue(true) - mockRead.mockReturnValue('0.1.0+deadbeef\n') + readRelayVersionMarkerSyncMock.mockReturnValue('0.1.0+deadbeef') expect(readLocalFullVersion('/local/relay')).toBe('0.1.0+deadbeef') }) @@ -63,7 +67,7 @@ describe('readLocalFullVersion', () => { it('throws when the .version file is empty', () => { mockExists.mockReturnValue(true) - mockRead.mockReturnValue(' \n') + readRelayVersionMarkerSyncMock.mockReturnValue('') expect(() => readLocalFullVersion('/local/relay')).toThrow(/is empty/) }) }) @@ -509,7 +513,9 @@ describe('relay GC claim', () => { const releaseScript = decodePowerShellCommand(mockExec.mock.calls[2]?.[1] ?? '') expect(ownerScript).toContain('Set-Content -LiteralPath') expect(ownerScript).toContain('.gc-claim/.gc-owner') - expect(releaseScript).toContain('Get-Content -LiteralPath') + expect(releaseScript).toContain('[System.IO.File]::Open') + expect(releaseScript).toContain('New-Object byte[] 1025') + expect(releaseScript).not.toContain('Get-Content') expect(releaseScript).toContain('-cne') expect(releaseScript).toContain('Remove-Item -LiteralPath') expect(releaseScript).toContain("'RELEASED'") @@ -570,6 +576,16 @@ describe('gcOldRelayVersions', () => { ).toBe(false) }) + it('does not inspect or delete entries from a capacity-limited listing', async () => { + mockExec.mockResolvedValueOnce( + `relay-0.1.0+aaa\n${RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL}\n` + ) + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + expect(mockExec).toHaveBeenCalledTimes(1) + }) + it('cleans only strict POSIX orphan tombstones even with no relay candidates', async () => { mockExec .mockResolvedValueOnce( diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index 9945c997ef2..473f0b15bee 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -5,12 +5,14 @@ // See: docs/ssh-relay-versioned-install-dirs.md import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' +import { readRelayVersionMarkerSync } from '../../shared/relay-version-marker' import type { SshConnection } from './ssh-connection' import { RELAY_REMOTE_DIR } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { relayRemoteDirSegments } from './ssh-relay-install-namespace' import { isRelayGcClaimOwned, releaseRelayGcClaimWithRetry, @@ -26,6 +28,7 @@ import { removeRemoteTreeCommand, writeRemoteEmptyFileCommand } from './ssh-remote-commands' +import { isRelayBaseDirectoryListingLimited } from './ssh-relay-base-directory-listing' import { getRemoteHostPlatform, isWindowsRemoteHost, @@ -77,7 +80,7 @@ export function readLocalFullVersion(localRelayDir: string): string { `This usually indicates a packaging or build problem; reinstall Orca.` ) } - const v = readFileSync(versionFile, 'utf-8').trim() + const v = readRelayVersionMarkerSync(versionFile) if (!v) { throw new Error( `Orca's local relay version marker at ${versionFile} is empty. ` + @@ -100,7 +103,8 @@ export function computeRemoteRelayDir( pathFlavor === 'windows' ? getRemoteHostPlatform('win32-x64') : getRemoteHostPlatform('linux-x64') - return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, `relay-${fullVersion}`) + // Why: shell and SFTP-relative builders must derive the same validated segments or the namespaces diverge. + return joinRemotePath(host, remoteHome, ...relayRemoteDirSegments(fullVersion, pathFlavor)) } /** @@ -190,6 +194,9 @@ export async function gcOldRelayVersions( } catch { return } + if (isRelayBaseDirectoryListingLimited(listing)) { + return + } const entries = listing .split('\n') .map((s) => s.trim()) diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 3ca55edfd69..9271a953ced 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -9,6 +9,7 @@ import { spawn as nodeSpawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import { getCanonicalUserDataPath } from '../persistence' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' export type RemoteOrcaCliRequest = { argv: string[] @@ -226,8 +227,7 @@ export async function runHostOrcaCliPassthrough( } class CappedOutputCollector { - private readonly chunks: Buffer[] = [] - private bytes = 0 + private readonly output = new GrowingByteBuffer() private truncated = false constructor(private readonly maxBytes: number) {} @@ -236,19 +236,17 @@ class CappedOutputCollector { if (this.truncated) { return } - const remaining = this.maxBytes - this.bytes + const remaining = this.maxBytes - this.output.byteLength if (chunk.length >= remaining) { - this.chunks.push(chunk.subarray(0, remaining)) - this.bytes = this.maxBytes + this.output.append(chunk.subarray(0, remaining)) this.truncated = true return } - this.chunks.push(chunk) - this.bytes += chunk.length + this.output.append(chunk) } toString(): string { - const text = Buffer.concat(this.chunks).toString('utf8') + const text = this.output.toString() return this.truncated ? `${text}\n[orca ssh cli] output truncated\n` : text } } diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 594ed2864d5..ea269cd79d6 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -11,6 +11,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { GENERATED_NODE_MANAGED_FILE_MAX_BYTES } from '../generated-node-bounded-file-reader' import { lockAgeSecondsCommand, tryCreateInstallLockCommand, @@ -172,6 +173,11 @@ describe('ssh remote command builders', () => { expect(script).toContain('markerCount===0&&pipes.length===0') expect(script).toContain('C:\\Program Files\\nodejs') expect(script).not.toContain('Win32_Process') + expect(script).toContain( + `readOrcaManagedFileWithinLimit(fs, path, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES})` + ) + expect(script).toContain('readOrcaManagedFileWithinLimit(fs,path.join(dir,name)).trim()') + expect(script).not.toContain('readFileSync') expect(listRelayBaseDirsCommand(windows, 'C:/Users/me/.orca-remote')).toContain( '-EncodedCommand' ) @@ -243,6 +249,9 @@ describe('ssh remote command builders', () => { expect(posixCommand).toContain('steal_generation + 1') expect(posixCommand).not.toContain('.next.') expect(posixCommand).toContain('trap') + expect(posixCommand).toContain('find "$steal_parent" -mindepth 1 -maxdepth 1') + expect(posixCommand).toContain('-exec rm -rf {} +') + expect(posixCommand).not.toContain('rm -rf "$steal_root".*') expect(windowsScript).toContain('$lock.steal') expect(windowsScript).toContain('$stealGeneration++') expect(windowsScript).toContain('-gt 1200') diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 155fb5ba66f..4a2c244df3c 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -1,4 +1,6 @@ import type { RemoteHostPlatform } from './ssh-remote-platform' +import { getRelayBaseDirectoryListingCommand } from './ssh-relay-base-directory-listing' +import { getWindowsRelayLivenessProbeSource } from './ssh-relay-liveness-probe-source' import { isWindowsRemoteHost, joinRemotePath, remoteDirname } from './ssh-remote-platform' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' import { shellEscape } from './ssh-connection-utils' @@ -103,17 +105,7 @@ export function probeRelayInstalledCommand( } export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string { - if (!isWindowsRemoteHost(host)) { - return `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true` - } - return powerShellCommand( - [ - `$base = ${powerShellLiteral(baseDir)}`, - 'if (Test-Path -LiteralPath $base -PathType Container) {', - 'Get-ChildItem -LiteralPath $base -Directory | ForEach-Object { $_.Name }', - '}' - ].join(' ') - ) + return getRelayBaseDirectoryListingCommand(host, baseDir) } export function probeDirectoryExistsCommand(host: RemoteHostPlatform, remotePath: string): string { @@ -154,35 +146,7 @@ export function relayLivenessProbeCommand( if (!windowsOptions) { return powerShellCommand("'ALIVE'") } - const js = [ - 'const fs=require("fs"),path=require("path"),net=require("net");', - 'const [dir,...seed]=process.argv.slice(1);', - 'const valid=/^\\\\\\\\[.?]\\\\pipe\\\\orca-relay-[0-9a-f]{20}$/i;', - 'const pipes=[];', - 'let markerCount=0;', - 'for(const p of seed){if(valid.test(p)&&!pipes.includes(p))pipes.push(p)}', - 'try{for(const name of fs.readdirSync(dir)){', - 'if(!name.startsWith(".windows-active-pipe-"))continue;', - 'markerCount++;', - 'const p=fs.readFileSync(path.join(dir,name),"utf8").trim();', - 'if(valid.test(p)&&!pipes.includes(p))pipes.push(p)', - '}}catch{}', - 'if(markerCount===0&&pipes.length===0){process.stdout.write("ALIVE");process.exit(0)}', - 'let i=0;', - 'function done(ok){process.stdout.write(ok?"ALIVE":"WAITING")}', - 'function next(){', - 'const pipe=pipes[i++];', - 'if(!pipe)return done(false);', - 'const s=net.connect(pipe);', - 'let settled=false;', - 'function finish(ok){if(settled)return;settled=true;s.destroy();if(ok)done(true);else next()}', - 's.setTimeout(200);', - 's.on("connect",()=>finish(true));', - 's.on("timeout",()=>finish(false));', - 's.on("error",()=>finish(false));', - '}', - 'next();' - ].join('') + const js = getWindowsRelayLivenessProbeSource() return commandWithNodePath( host, windowsOptions.nodePath, diff --git a/src/main/ssh/ssh-remote-node-candidate-probe.test.ts b/src/main/ssh/ssh-remote-node-candidate-probe.test.ts new file mode 100644 index 00000000000..ad9089a38f4 --- /dev/null +++ b/src/main/ssh/ssh-remote-node-candidate-probe.test.ts @@ -0,0 +1,91 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + buildPosixRemoteNodeCandidateProbe, + REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL, + REMOTE_NODE_CANDIDATE_MAX_COUNT, + REMOTE_NODE_CANDIDATE_MAX_UTF8_BYTES, + REMOTE_NODE_PROFILE_MAX_BYTES +} from './ssh-remote-node-candidate-probe' + +describe('POSIX remote Node candidate probe', () => { + it('uses bounded profile reads, batched directory iteration, and bounded output', () => { + const script = buildPosixRemoteNodeCandidateProbe() + + expect(script).toContain('dd if="$nvm_file" bs=1024 count=65') + expect(script).toContain(`-le ${REMOTE_NODE_PROFILE_MAX_BYTES}`) + expect(script).toContain(`find "$candidate_root" -mindepth 1 -maxdepth 1 ! -name '.*'`) + expect(script).toContain('-exec sh -c') + expect(script).toContain('{} +') + expect(script).toContain('| sort') + expect(script).toContain(`candidate_count>=${REMOTE_NODE_CANDIDATE_MAX_COUNT}`) + expect(script).toContain(`output_bytes+line_bytes>${REMOTE_NODE_CANDIDATE_MAX_UTF8_BYTES}`) + expect(script).toContain(REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL) + expect(script).not.toContain('/*/') + }) + + it.runIf(process.platform !== 'win32')( + 'accepts an exact-limit profile and ignores its first byte of overflow', + () => { + const home = mkdtempSync(join(tmpdir(), 'orca-node-profile-limit-')) + try { + const nodePath = join(home, 'tilde-nvm', 'versions', 'node', 'v20', 'bin', 'node') + mkdirSync(join(home, 'tilde-nvm', 'versions', 'node', 'v20', 'bin'), { + recursive: true + }) + writeFileSync(nodePath, '#!/bin/sh\nexit 0\n') + chmodSync(nodePath, 0o755) + const assignment = 'export NVM_DIR=~/tilde-nvm\n' + const exactProfile = + assignment + '#'.repeat(REMOTE_NODE_PROFILE_MAX_BYTES - assignment.length) + writeFileSync(join(home, '.profile'), exactProfile) + const script = buildPosixRemoteNodeCandidateProbe() + const environment = { ...process.env, HOME: home, NVM_DIR: '' } + + const exactOutput = execFileSync('/bin/sh', ['-c', script], { + encoding: 'utf8', + env: environment + }) + expect(exactOutput.split('\n')).toContain(nodePath) + + writeFileSync(join(home, '.profile'), `${exactProfile}x`) + const oversizedOutput = execFileSync('/bin/sh', ['-c', script], { + encoding: 'utf8', + env: environment + }) + expect(oversizedOutput.split('\n')).not.toContain(nodePath) + } finally { + rmSync(home, { recursive: true, force: true }) + } + } + ) + + it.runIf(process.platform !== 'win32')( + 'preserves lexical version ordering within each bounded manager directory', + () => { + const home = mkdtempSync(join(tmpdir(), 'orca-node-version-order-')) + try { + const nvmDir = join(home, 'nvm') + const newer = join(nvmDir, 'versions', 'node', 'v22', 'bin', 'node') + const older = join(nvmDir, 'versions', 'node', 'v18', 'bin', 'node') + for (const nodePath of [newer, older]) { + mkdirSync(join(nodePath, '..'), { recursive: true }) + writeFileSync(nodePath, '#!/bin/sh\nexit 0\n') + chmodSync(nodePath, 0o755) + } + + const output = execFileSync('/bin/sh', ['-c', buildPosixRemoteNodeCandidateProbe()], { + encoding: 'utf8', + env: { ...process.env, HOME: home, NVM_DIR: nvmDir } + }).split('\n') + + expect(output.indexOf(older)).toBeLessThan(output.indexOf(newer)) + } finally { + rmSync(home, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/ssh/ssh-remote-node-candidate-probe.ts b/src/main/ssh/ssh-remote-node-candidate-probe.ts new file mode 100644 index 00000000000..ca458712ee6 --- /dev/null +++ b/src/main/ssh/ssh-remote-node-candidate-probe.ts @@ -0,0 +1,133 @@ +export const REMOTE_NODE_PROFILE_MAX_BYTES = 64 * 1024 +export const REMOTE_NODE_NVM_DIR_MAX_BYTES = 4 * 1024 +export const REMOTE_NODE_CANDIDATE_MAX_COUNT = 256 +export const REMOTE_NODE_CANDIDATE_MAX_UTF8_BYTES = 64 * 1024 +export const REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL = '__ORCA_NODE_CANDIDATES_TOO_LARGE__' + +type RemoteNodeCandidateProbeOptions = { + maxCandidates?: number + maxUtf8Bytes?: number +} + +function positiveInteger(value: number | undefined, fallback: number, name: string): number { + const resolved = value ?? fallback + if (!Number.isSafeInteger(resolved) || resolved < 1) { + throw new Error(`${name} must be a positive integer`) + } + return resolved +} + +export function isRemoteNodeCandidateProbeLimited(output: string): boolean { + return output.split(/\r?\n/).some((line) => line.trim() === REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL) +} + +export function buildPosixRemoteNodeCandidateProbe( + options?: RemoteNodeCandidateProbeOptions +): string { + const maxCandidates = positiveInteger( + options?.maxCandidates, + REMOTE_NODE_CANDIDATE_MAX_COUNT, + 'maxCandidates' + ) + const maxUtf8Bytes = positiveInteger( + options?.maxUtf8Bytes, + REMOTE_NODE_CANDIDATE_MAX_UTF8_BYTES, + 'maxUtf8Bytes' + ) + + return ` +nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} +nvm_dirs_bytes=$(LC_ALL=C printf %s "$nvm_dirs" | wc -c | tr -d '[:space:]') +[ "\${nvm_dirs_bytes:-${REMOTE_NODE_NVM_DIR_MAX_BYTES + 1}}" -le ${REMOTE_NODE_NVM_DIR_MAX_BYTES} ] 2>/dev/null || nvm_dirs="$HOME/.nvm" +for nvm_file in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc" +do + [ -r "$nvm_file" ] || continue + profile_bytes=$(dd if="$nvm_file" bs=1024 count=65 2>/dev/null | wc -c | tr -d '[:space:]') + [ "\${profile_bytes:-${REMOTE_NODE_PROFILE_MAX_BYTES + 1}}" -le ${REMOTE_NODE_PROFILE_MAX_BYTES} ] 2>/dev/null || continue + nvm_dir_from_file=$(dd if="$nvm_file" bs=1024 count=64 2>/dev/null | sed -n 's/^[[:space:]]*export[[:space:]][[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p; s/^[[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p' | tail -n 1) + case "$nvm_dir_from_file" in + \\"*\\") nvm_dir_from_file=\${nvm_dir_from_file#\\"}; nvm_dir_from_file=\${nvm_dir_from_file%%\\"*} ;; + \\'*\\') nvm_dir_from_file=\${nvm_dir_from_file#\\'}; nvm_dir_from_file=\${nvm_dir_from_file%%\\'*} ;; + *) nvm_dir_from_file=\${nvm_dir_from_file%%[[:space:]]*} ;; + esac + case "$nvm_dir_from_file" in + '$HOME'*) nvm_dir_from_file="$HOME\${nvm_dir_from_file#'$HOME'}" ;; + "~/"*) nvm_dir_from_file="$HOME/\${nvm_dir_from_file#\\~/}" ;; + esac + nvm_dir_bytes=$(LC_ALL=C printf %s "$nvm_dir_from_file" | wc -c | tr -d '[:space:]') + [ "\${nvm_dir_bytes:-${REMOTE_NODE_NVM_DIR_MAX_BYTES + 1}}" -le ${REMOTE_NODE_NVM_DIR_MAX_BYTES} ] 2>/dev/null || continue + [ -n "$nvm_dir_from_file" ] && nvm_dirs="$nvm_dirs +$nvm_dir_from_file" +done + +find_node_candidates() { + candidate_root=$1 + candidate_suffix=$2 + [ -d "$candidate_root" ] || return 0 + find "$candidate_root" -mindepth 1 -maxdepth 1 ! -name '.*' -exec sh -c ' + candidate_suffix=$1 + shift + for candidate_dir + do + candidate=$candidate_dir/$candidate_suffix + [ -x "$candidate" ] && printf "%s\\n" "$candidate" + done + ' sh "$candidate_suffix" {} + | LC_ALL=C awk ' + { + line_bytes=length($0)+1 + if(candidate_count>=${maxCandidates} || output_bytes+line_bytes>${maxUtf8Bytes}) { + print "${REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL}" + exit + } + print + candidate_count++ + output_bytes+=line_bytes + } + ' | sort +} + +{ + command -v node 2>/dev/null + printf '%s\\n' "$nvm_dirs" | while IFS= read -r nvm_dir + do + [ -n "$nvm_dir" ] || continue + find_node_candidates "$nvm_dir/versions/node" "bin/node" + done + for candidate in \\ + /usr/local/bin/node \\ + /opt/homebrew/bin/node \\ + "$HOME/.local/bin/node" \\ + "$HOME/.fnm/aliases/default/bin/node" + do + [ -x "$candidate" ] && printf '%s\\n' "$candidate" + done + find_node_candidates "$HOME/.fnm/node-versions" "installation/bin/node" + find_node_candidates "$HOME/.local/share/fnm/node-versions" "installation/bin/node" + candidate="$HOME/.local/share/mise/shims/node"; [ -x "$candidate" ] && printf '%s\\n' "$candidate" + find_node_candidates "$HOME/.local/share/mise/installs/node" "bin/node" + candidate="$HOME/.asdf/shims/node"; [ -x "$candidate" ] && printf '%s\\n' "$candidate" + find_node_candidates "$HOME/.asdf/installs/nodejs" "bin/node" + candidate="$HOME/.volta/bin/node"; [ -x "$candidate" ] && printf '%s\\n' "$candidate" + find_node_candidates "/usr/local/n/versions/node" "bin/node" +} | LC_ALL=C awk ' + { + candidate=$0 + if(candidate=="${REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL}") { + print candidate + exit + } + if(candidate=="" || seen[candidate]) next + line_bytes=length(candidate)+1 + if(candidate_count>=${maxCandidates} || output_bytes+line_bytes>${maxUtf8Bytes}) { + print "${REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL}" + exit + } + seen[candidate]=1 + print candidate + candidate_count++ + output_bytes+=line_bytes + } +' +true +` +} diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index a48687284ad..ff8846eb3b2 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SshConnection } from './ssh-connection' +import { REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL } from './ssh-remote-node-candidate-probe' import { getRemoteHostPlatform } from './ssh-remote-platform' const execCommandMock = vi.hoisted(() => vi.fn()) @@ -99,7 +100,9 @@ describe('resolveRemoteNodePath', () => { await resolveRemoteNodePath(conn) const callScript = execCommandMock.mock.calls[0]![1] as string - expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') + expect(callScript).toContain( + 'find_node_candidates "$HOME/.local/share/mise/installs/node" "bin/node"' + ) }) it('probes asdf install directories', async () => { @@ -110,7 +113,7 @@ describe('resolveRemoteNodePath', () => { await resolveRemoteNodePath(conn) const callScript = execCommandMock.mock.calls[0]![1] as string - expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') + expect(callScript).toContain('find_node_candidates "$HOME/.asdf/installs/nodejs" "bin/node"') }) it('probes volta bin directory', async () => { @@ -134,10 +137,10 @@ describe('resolveRemoteNodePath', () => { const callScript = execCommandMock.mock.calls[0]![1] as string expect(callScript).toContain('nvm_dirs=${NVM_DIR:-"$HOME/.nvm"}') expect(callScript).toContain('NVM_DIR[[:space:]]*=') - expect(callScript).toContain('"$nvm_dir"/versions/node/*/bin/node') + expect(callScript).toContain('find_node_candidates "$nvm_dir/versions/node" "bin/node"') }) - it('quotes version-manager directory prefixes while leaving globs active', async () => { + it('streams quoted version-manager directories without shell globs', async () => { execCommandMock .mockResolvedValueOnce('/home/u/.fnm/node-versions/v20.11.0/installation/bin/node\n') .mockResolvedValueOnce('v20.11.0\n') @@ -145,10 +148,17 @@ describe('resolveRemoteNodePath', () => { await resolveRemoteNodePath(conn) const callScript = execCommandMock.mock.calls[0]![1] as string - expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node') - expect(callScript).toContain('"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node') - expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') - expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') + expect(callScript).toContain( + 'find_node_candidates "$HOME/.fnm/node-versions" "installation/bin/node"' + ) + expect(callScript).toContain( + 'find_node_candidates "$HOME/.local/share/fnm/node-versions" "installation/bin/node"' + ) + expect(callScript).toContain( + 'find_node_candidates "$HOME/.local/share/mise/installs/node" "bin/node"' + ) + expect(callScript).toContain('find_node_candidates "$HOME/.asdf/installs/nodejs" "bin/node"') + expect(callScript).not.toContain('/*/') }) it('probes fnm XDG data directory installs', async () => { @@ -276,6 +286,20 @@ describe('resolveRemoteNodePath', () => { }) }) + it('falls back without probing a partial capacity-limited candidate inventory', async () => { + execCommandMock + .mockResolvedValueOnce(`/old/node\n${REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL}\n`) + .mockResolvedValueOnce('/bin/bash') + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.nvm/versions/node/v20.11.0/bin/node' + ) + + expect(execCommandMock.mock.calls[1]?.[1]).toBe('echo "${SHELL:-/bin/sh}"') + }) + it('falls back to the login shell when every path-probe candidate is too old', async () => { execCommandMock .mockResolvedValueOnce('/old/node\n') // path probe diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index 1f2af057039..5ec0c0e2452 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -15,6 +15,10 @@ import { } from './ssh-remote-node-toolchain-probe' import { isSshSessionLimitError } from './ssh-session-limit-error' import { buildSshLoginShellCommand } from './ssh-login-shell-command' +import { + buildPosixRemoteNodeCandidateProbe, + isRemoteNodeCandidateProbeLimited +} from './ssh-remote-node-candidate-probe' // Why: the login-shell fallback catches custom PATH setups in ~/.profile that // the path probes don't cover. Interactive configs (conda prompts, etc.) can @@ -58,54 +62,13 @@ async function tryResolveViaKnownPaths( conn: SshConnection, options?: RemoteNodeResolutionOptions ): Promise { - const script = ` -command -v node 2>/dev/null -nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} -for nvm_file in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc" -do - [ -r "$nvm_file" ] || continue - nvm_dir_from_file=$(sed -n 's/^[[:space:]]*export[[:space:]][[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p; s/^[[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p' "$nvm_file" | tail -n 1) - case "$nvm_dir_from_file" in - \\"*\\") nvm_dir_from_file=\${nvm_dir_from_file#\\"}; nvm_dir_from_file=\${nvm_dir_from_file%%\\"*} ;; - \\'*\\') nvm_dir_from_file=\${nvm_dir_from_file#\\'}; nvm_dir_from_file=\${nvm_dir_from_file%%\\'*} ;; - *) nvm_dir_from_file=\${nvm_dir_from_file%%[[:space:]]*} ;; - esac - case "$nvm_dir_from_file" in - '$HOME'*) nvm_dir_from_file="$HOME\${nvm_dir_from_file#'$HOME'}" ;; - "~/"*) nvm_dir_from_file="$HOME/\${nvm_dir_from_file#\\~/}" ;; - esac - [ -n "$nvm_dir_from_file" ] && nvm_dirs="$nvm_dirs -$nvm_dir_from_file" -done -printf '%s\\n' "$nvm_dirs" | while IFS= read -r nvm_dir -do - [ -n "$nvm_dir" ] || continue - for candidate in "$nvm_dir"/versions/node/*/bin/node - do - [ -x "$candidate" ] && printf '%s\\n' "$candidate" - done -done -for candidate in \\ - /usr/local/bin/node \\ - /opt/homebrew/bin/node \\ - "$HOME/.local/bin/node" \\ - "$HOME/.fnm/aliases/default/bin/node" \\ - "$HOME/.fnm/node-versions"/*/installation/bin/node \\ - "$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \\ - "$HOME/.local/share/mise/shims/node" \\ - "$HOME/.local/share/mise/installs/node"/*/bin/node \\ - "$HOME/.asdf/shims/node" \\ - "$HOME/.asdf/installs/nodejs"/*/bin/node \\ - "$HOME/.volta/bin/node" \\ - /usr/local/n/versions/node/*/bin/node -do - [ -x "$candidate" ] && printf '%s\\n' "$candidate" -done -true -` + const script = buildPosixRemoteNodeCandidateProbe() try { const result = await execCommandWithOptionalOptions(conn, script, signalOnlyOptions(options)) + if (isRemoteNodeCandidateProbeLimited(result)) { + return null + } const seen = new Set() for (const line of result.split('\n')) { const candidate = line.trim() diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 3d1c3e60793..e99cc30cb69 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -70,9 +70,17 @@ describe('runRemoteOrcaCli', () => { getUnreadMessages: vi.fn((handle: string) => messages.filter((message) => message.to_handle === handle && message.read_at === null) ), + countUnreadMessages: vi.fn( + (handle: string) => + messages.filter((message) => message.to_handle === handle && message.read_at === null) + .length + ), getAllMessagesForHandle: vi.fn((handle: string) => messages.filter((message) => message.to_handle === handle) ), + countAllMessagesForHandle: vi.fn( + (handle: string) => messages.filter((message) => message.to_handle === handle).length + ), markAsRead: vi.fn((ids: string[]) => { for (const message of messages) { if (ids.includes(message.id)) { diff --git a/src/main/ssh/ssh-stream-reader-memory.test.ts b/src/main/ssh/ssh-stream-reader-memory.test.ts new file mode 100644 index 00000000000..8cbe291dc92 --- /dev/null +++ b/src/main/ssh/ssh-stream-reader-memory.test.ts @@ -0,0 +1,432 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + MAX_GIT_RESPONSE_STREAM_BYTES, + MAX_GIT_RESPONSE_STREAM_CHUNKS, + STREAM_CHUNK_SIZE, + MessageType, + encodeFrame +} from './relay-protocol' +import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer' +import { readFileViaStream } from './ssh-filesystem-stream-reader' +import { requestGitStreamable } from './ssh-git-response-stream-reader' +import { + MAX_PRE_METADATA_STREAM_ENCODED_BYTES, + PreMetadataStreamFrameBuffer, + SshPreMetadataStreamBudget, + SshStreamAssemblyBudget +} from './ssh-stream-reader-memory' + +type Notification = { method: string; params: Record | undefined } + +function createStreamMux(): { + mux: Record + emit: (method: string, params: Record) => void + resolveRequest: (value: unknown) => void + rejectRequest: (error: Error) => void + dispose: (reason: 'shutdown' | 'connection_lost') => void + notifications: Notification[] +} { + const handlers = new Map) => void>>() + const disposeHandlers = new Set<(reason: 'shutdown' | 'connection_lost') => void>() + const notifications: Notification[] = [] + let resolveRequest!: (value: unknown) => void + let rejectRequest!: (error: Error) => void + const requestPromise = new Promise((resolve, reject) => { + resolveRequest = resolve + rejectRequest = reject + }) + const mux = { + request: vi.fn(() => requestPromise), + notify: vi.fn((method: string, params?: Record) => { + notifications.push({ method, params }) + }), + onNotificationByMethod: vi.fn( + (method: string, handler: (params: Record) => void) => { + const current = handlers.get(method) ?? new Set() + current.add(handler) + handlers.set(method, current) + return () => current.delete(handler) + } + ), + onDispose: vi.fn((handler: (reason: 'shutdown' | 'connection_lost') => void) => { + disposeHandlers.add(handler) + return () => disposeHandlers.delete(handler) + }), + isDisposed: vi.fn(() => false) + } + return { + mux, + emit: (method, params) => { + for (const handler of handlers.get(method) ?? []) { + handler(params) + } + }, + resolveRequest, + rejectRequest, + dispose: (reason) => { + for (const handler of disposeHandlers) { + handler(reason) + } + }, + notifications + } +} + +async function flushRequestResolution(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function encodeMuxResponse(requestId: number, result: unknown, sequence: number): Buffer { + return encodeFrame( + MessageType.Regular, + sequence, + 0, + Buffer.from(JSON.stringify({ jsonrpc: '2.0', id: requestId, result })) + ) +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('SSH stream assembly aggregate budget', () => { + it('bounds concurrent reservations and releases them idempotently', () => { + const budget = new SshStreamAssemblyBudget(10) + const releaseSix = budget.reserve(6) + const releaseFour = budget.reserve(4) + + expect(releaseSix).not.toBeNull() + expect(releaseFour).not.toBeNull() + expect(budget.reserve(1)).toBeNull() + expect(budget.retainedBytes).toBe(10) + + releaseSix?.() + releaseSix?.() + expect(budget.retainedBytes).toBe(4) + expect(budget.reserve(6)).not.toBeNull() + }) +}) + +describe('SSH pre-metadata stream aggregate budget', () => { + it('bounds concurrent frame buffers by count and bytes', () => { + const budget = new SshPreMetadataStreamBudget(2, 5) + const first = new PreMetadataStreamFrameBuffer(budget) + const second = new PreMetadataStreamFrameBuffer(budget) + const third = new PreMetadataStreamFrameBuffer(budget) + + expect(first.push({ kind: 'chunk', params: { streamId: 1, seq: 0, data: 'abc' } })).toBe(true) + expect(second.push({ kind: 'chunk', params: { streamId: 2, seq: 0, data: 'de' } })).toBe(true) + expect(third.push({ kind: 'end', params: { streamId: 3 } })).toBe(false) + expect(budget.retainedFrames).toBe(2) + expect(budget.retainedBytes).toBe(5) + + first.clear() + expect(third.push({ kind: 'end', params: { streamId: 3 } })).toBe(true) + expect(budget.retainedFrames).toBe(2) + second.clear() + third.clear() + expect(budget.retainedFrames).toBe(0) + expect(budget.retainedBytes).toBe(0) + }) + + it('releases shared reservations on success, stream error, request timeout, and disconnect', async () => { + const budget = new SshPreMetadataStreamBudget(4, 64) + const fileHarness = createStreamMux() + const errorHarness = createStreamMux() + const gitHarness = createStreamMux() + const disconnectHarness = createStreamMux() + const fileResult = readFileViaStream( + fileHarness.mux as never, + 'empty.txt', + undefined, + new SshStreamAssemblyBudget(1), + budget + ) + const errorResult = readFileViaStream( + errorHarness.mux as never, + 'error.txt', + undefined, + new SshStreamAssemblyBudget(1), + budget + ) + const gitResult = requestGitStreamable( + gitHarness.mux as never, + 'git.diff', + {}, + undefined, + new SshStreamAssemblyBudget(1), + budget + ) + const disconnectResult = readFileViaStream( + disconnectHarness.mux as never, + 'disconnected.txt', + undefined, + new SshStreamAssemblyBudget(1), + budget + ) + fileHarness.emit('fs.streamEnd', { streamId: 1 }) + errorHarness.emit('fs.streamError', { streamId: 2, message: 'remote read failed' }) + gitHarness.emit('git.responseEnd', { streamId: 3 }) + disconnectHarness.emit('fs.streamEnd', { streamId: 4 }) + expect(budget.retainedFrames).toBe(4) + + const streamRejection = expect(errorResult).rejects.toThrow('remote read failed') + const gitRejection = expect(gitResult).rejects.toThrow(/timed out/) + const disconnectRejection = expect(disconnectResult).rejects.toMatchObject({ + code: 'CONNECTION_LOST' + }) + fileHarness.resolveRequest({ streamId: 1, totalSize: 0, isBinary: false }) + errorHarness.resolveRequest({ streamId: 2, totalSize: 1, isBinary: false }) + gitHarness.rejectRequest(new Error('Request "git.diff" timed out after 30ms')) + disconnectHarness.dispose('connection_lost') + + await expect(fileResult).resolves.toEqual({ content: '', isBinary: false }) + await streamRejection + await gitRejection + await disconnectRejection + expect(budget.retainedFrames).toBe(0) + expect(budget.retainedBytes).toBe(0) + }) +}) + +describe('SSH Git response stream memory bounds', () => { + it('rejects and cancels an oversized stream marker', async () => { + const harness = createStreamMux() + const result = requestGitStreamable(harness.mux as never, 'git.diff', {}) + harness.resolveRequest({ + __orcaGitResponseStream: { + streamId: 7, + totalBytes: MAX_GIT_RESPONSE_STREAM_BYTES + 1, + chunkCount: 1 + } + }) + + await expect(result).rejects.toThrow(/exceeds client limit/) + expect(harness.notifications).toContainEqual({ + method: 'git.cancelResponseStream', + params: { streamId: 7 } + }) + }) + + it('rejects an excessive declared chunk count', async () => { + const harness = createStreamMux() + const result = requestGitStreamable(harness.mux as never, 'git.diff', {}) + harness.resolveRequest({ + __orcaGitResponseStream: { + streamId: 8, + totalBytes: 1, + chunkCount: MAX_GIT_RESPONSE_STREAM_CHUNKS + 1 + } + }) + + await expect(result).rejects.toThrow(/exceeds client limit/) + }) + + it('drops an oversized foreign frame received before the stream marker', async () => { + const harness = createStreamMux() + const result = requestGitStreamable(harness.mux as never, 'git.diff', {}) + harness.emit('git.responseChunk', { + streamId: 99, + seq: 0, + data: 'A'.repeat(MAX_PRE_METADATA_STREAM_ENCODED_BYTES + 1) + }) + const payload = Buffer.from('{"ok":true}') + harness.resolveRequest({ + __orcaGitResponseStream: { streamId: 9, totalBytes: payload.length, chunkCount: 1 } + }) + await flushRequestResolution() + harness.emit('git.responseChunk', { streamId: 9, seq: 0, data: payload.toString('base64') }) + harness.emit('git.responseEnd', { streamId: 9 }) + + await expect(result).resolves.toEqual({ ok: true }) + }) + + it('rejects an encoded chunk larger than the remaining declared bytes', async () => { + const harness = createStreamMux() + const result = requestGitStreamable(harness.mux as never, 'git.diff', {}) + harness.resolveRequest({ + __orcaGitResponseStream: { streamId: 10, totalBytes: 1, chunkCount: 1 } + }) + await flushRequestResolution() + harness.emit('git.responseChunk', { streamId: 10, seq: 0, data: 'A'.repeat(1_000_000) }) + + await expect(result).rejects.toThrow(/exceeds 1 remaining bytes/) + }) + + it('reassembles a valid result without changing its value', async () => { + const harness = createStreamMux() + const expected = { answer: 'unchanged', count: 2 } + const payload = Buffer.from(JSON.stringify(expected)) + const budget = new SshStreamAssemblyBudget(payload.length) + const result = requestGitStreamable(harness.mux as never, 'git.diff', {}, undefined, budget) + const first = payload.subarray(0, 7) + const second = payload.subarray(7) + harness.resolveRequest({ + __orcaGitResponseStream: { streamId: 11, totalBytes: payload.length, chunkCount: 2 } + }) + await flushRequestResolution() + expect(budget.retainedBytes).toBe(payload.length) + harness.emit('git.responseChunk', { streamId: 11, seq: 0, data: first.toString('base64') }) + harness.emit('git.responseChunk', { streamId: 11, seq: 1, data: second.toString('base64') }) + harness.emit('git.responseEnd', { streamId: 11 }) + + await expect(result).resolves.toEqual(expected) + expect(budget.retainedBytes).toBe(0) + }) + + it('rejects and cancels when the aggregate assembly budget is unavailable', async () => { + const harness = createStreamMux() + const budget = new SshStreamAssemblyBudget(1) + const held = budget.reserve(1) + const result = requestGitStreamable(harness.mux as never, 'git.diff', {}, undefined, budget) + harness.resolveRequest({ + __orcaGitResponseStream: { streamId: 16, totalBytes: 1, chunkCount: 1 } + }) + + await expect(result).rejects.toThrow(/Active SSH stream assembly would exceed/) + expect(harness.notifications).toContainEqual({ + method: 'git.cancelResponseStream', + params: { streamId: 16 } + }) + held?.() + }) +}) + +describe('SSH filesystem stream memory bounds', () => { + it('releases every active assembly immediately when the shared mux disconnects', async () => { + const dataHandlers: ((data: Buffer) => void)[] = [] + const transport: MultiplexerTransport = { + write: vi.fn(), + onData: (handler) => dataHandlers.push(handler), + onClose: vi.fn(), + close: vi.fn() + } + const mux = new SshChannelMultiplexer(transport) + const budget = new SshStreamAssemblyBudget(2) + const first = readFileViaStream(mux, 'first.txt', undefined, budget) + const second = readFileViaStream(mux, 'second.txt', undefined, budget) + const firstRejection = expect(first).rejects.toMatchObject({ code: 'CONNECTION_LOST' }) + const secondRejection = expect(second).rejects.toMatchObject({ code: 'CONNECTION_LOST' }) + + dataHandlers[0]( + Buffer.concat([ + encodeMuxResponse(1, { streamId: 21, totalSize: 1, isBinary: false }, 1), + encodeMuxResponse(2, { streamId: 22, totalSize: 1, isBinary: false }, 2) + ]) + ) + await flushRequestResolution() + expect(budget.retainedBytes).toBe(2) + + mux.dispose('connection_lost') + + expect(budget.retainedBytes).toBe(0) + await firstRejection + await secondRejection + }) + + it('drops an oversized foreign frame received before file metadata', async () => { + const harness = createStreamMux() + const result = readFileViaStream(harness.mux as never, 'tiny.txt') + harness.emit('fs.streamChunk', { + streamId: 90, + seq: 0, + data: 'A'.repeat(MAX_PRE_METADATA_STREAM_ENCODED_BYTES + 1) + }) + harness.resolveRequest({ + streamId: 12, + totalSize: 1, + isBinary: false, + resultEncoding: 'utf-8' + }) + await flushRequestResolution() + harness.emit('fs.streamChunk', { + streamId: 12, + seq: 0, + data: Buffer.from('a').toString('base64') + }) + harness.emit('fs.streamEnd', { streamId: 12 }) + + await expect(result).resolves.toEqual({ content: 'a', isBinary: false }) + }) + + it('validates encoded size before decoding a file chunk', async () => { + const harness = createStreamMux() + const result = readFileViaStream(harness.mux as never, 'tiny.txt') + harness.resolveRequest({ streamId: 13, totalSize: 1, isBinary: false }) + await flushRequestResolution() + harness.emit('fs.streamChunk', { streamId: 13, seq: 0, data: 'A'.repeat(1_000_000) }) + + await expect(result).rejects.toThrow(/Encoded chunk length mismatch/) + expect(harness.notifications).toContainEqual({ + method: 'fs.cancelStream', + params: { streamId: 13 } + }) + }) + + it('cancels a file stream that stops making progress', async () => { + vi.useFakeTimers() + const harness = createStreamMux() + const result = readFileViaStream(harness.mux as never, 'stalled.txt', { + inactivityTimeoutMs: 50 + }) + harness.resolveRequest({ streamId: 14, totalSize: 1, isBinary: false }) + await flushRequestResolution() + + const rejection = expect(result).rejects.toThrow(/stalled/) + await vi.advanceTimersByTimeAsync(51) + await rejection + expect(harness.notifications).toContainEqual({ + method: 'fs.cancelStream', + params: { streamId: 14 } + }) + }) + + it('resets the inactivity deadline on progress and preserves valid content', async () => { + vi.useFakeTimers() + const harness = createStreamMux() + const first = Buffer.alloc(STREAM_CHUNK_SIZE, 0x61) + const second = Buffer.from('z') + const budget = new SshStreamAssemblyBudget(first.length + second.length) + const result = readFileViaStream( + harness.mux as never, + 'progress.txt', + { inactivityTimeoutMs: 50 }, + budget + ) + harness.resolveRequest({ + streamId: 15, + totalSize: first.length + second.length, + isBinary: false, + resultEncoding: 'utf-8' + }) + await flushRequestResolution() + expect(budget.retainedBytes).toBe(first.length + second.length) + await vi.advanceTimersByTimeAsync(40) + harness.emit('fs.streamChunk', { streamId: 15, seq: 0, data: first.toString('base64') }) + await vi.advanceTimersByTimeAsync(40) + harness.emit('fs.streamChunk', { streamId: 15, seq: 1, data: second.toString('base64') }) + harness.emit('fs.streamEnd', { streamId: 15 }) + + await expect(result).resolves.toEqual({ + content: `${'a'.repeat(STREAM_CHUNK_SIZE)}z`, + isBinary: false + }) + expect(budget.retainedBytes).toBe(0) + }) + + it('rejects and cancels when file assembly would exceed the aggregate budget', async () => { + const harness = createStreamMux() + const budget = new SshStreamAssemblyBudget(1) + const held = budget.reserve(1) + const result = readFileViaStream(harness.mux as never, 'tiny.txt', undefined, budget) + harness.resolveRequest({ streamId: 17, totalSize: 1, isBinary: false }) + + await expect(result).rejects.toThrow(/Active SSH stream assembly would exceed/) + expect(harness.notifications).toContainEqual({ + method: 'fs.cancelStream', + params: { streamId: 17 } + }) + held?.() + }) +}) diff --git a/src/main/ssh/ssh-stream-reader-memory.ts b/src/main/ssh/ssh-stream-reader-memory.ts new file mode 100644 index 00000000000..c599a11a58b --- /dev/null +++ b/src/main/ssh/ssh-stream-reader-memory.ts @@ -0,0 +1,205 @@ +export const STREAM_READER_INACTIVITY_TIMEOUT_MS = 30_000 +export const MAX_PRE_METADATA_STREAM_FRAMES = 64 +export const MAX_PRE_METADATA_STREAM_ENCODED_BYTES = 8 * 1024 * 1024 +export const MAX_SSH_PRE_METADATA_STREAM_FRAMES = 256 +export const MAX_SSH_PRE_METADATA_STREAM_RETAINED_BYTES = 32 * 1024 * 1024 +export const MAX_SSH_STREAM_ASSEMBLY_BYTES = 128 * 1024 * 1024 + +export type BufferedStreamFrame = { + kind: 'chunk' | 'end' | 'error' + params: Record +} + +type RetainedStreamFrame = { + frame: BufferedStreamFrame + encodedBytes: number + release: () => void +} + +export class SshPreMetadataStreamBudget { + private frames = 0 + private bytes = 0 + + constructor( + readonly maxFrames: number, + readonly maxBytes: number + ) {} + + reserve(bytes: number): (() => void) | null { + if ( + !Number.isSafeInteger(bytes) || + bytes < 0 || + this.frames >= this.maxFrames || + bytes > this.maxBytes - this.bytes + ) { + return null + } + this.frames += 1 + this.bytes += bytes + let released = false + return () => { + if (released) { + return + } + released = true + this.frames -= 1 + this.bytes -= bytes + } + } + + get retainedFrames(): number { + return this.frames + } + + get retainedBytes(): number { + return this.bytes + } +} + +export const defaultSshPreMetadataStreamBudget = new SshPreMetadataStreamBudget( + MAX_SSH_PRE_METADATA_STREAM_FRAMES, + MAX_SSH_PRE_METADATA_STREAM_RETAINED_BYTES +) + +export class PreMetadataStreamFrameBuffer { + private frames: RetainedStreamFrame[] = [] + private encodedBytes = 0 + + constructor(private readonly budget: SshPreMetadataStreamBudget) {} + + push(frame: BufferedStreamFrame): boolean { + const retainedFrame = retainStreamFrameFields(frame) + const frameBytes = retainedStreamFrameBytes(retainedFrame) + if (frameBytes > MAX_PRE_METADATA_STREAM_ENCODED_BYTES) { + return false + } + while ( + this.frames.length >= MAX_PRE_METADATA_STREAM_FRAMES || + this.encodedBytes + frameBytes > MAX_PRE_METADATA_STREAM_ENCODED_BYTES + ) { + this.shift() + } + let release = this.budget.reserve(frameBytes) + while (!release && this.frames.length > 0) { + this.shift() + release = this.budget.reserve(frameBytes) + } + if (!release) { + return false + } + this.frames.push({ frame: retainedFrame, encodedBytes: frameBytes, release }) + this.encodedBytes += frameBytes + return true + } + + shift(): BufferedStreamFrame | undefined { + const retained = this.frames.shift() + if (retained) { + this.encodedBytes -= retained.encodedBytes + retained.release() + } + return retained?.frame + } + + get length(): number { + return this.frames.length + } + + clear(): void { + while (this.frames.length > 0) { + this.shift() + } + } +} + +function retainStreamFrameFields(frame: BufferedStreamFrame): BufferedStreamFrame { + const { params } = frame + if (frame.kind === 'chunk') { + return { + kind: frame.kind, + params: { + ...(typeof params.streamId === 'number' ? { streamId: params.streamId } : {}), + ...(typeof params.seq === 'number' ? { seq: params.seq } : {}), + ...(typeof params.data === 'string' ? { data: params.data } : {}) + } + } + } + if (frame.kind === 'error') { + return { + kind: frame.kind, + params: { + ...(typeof params.streamId === 'number' ? { streamId: params.streamId } : {}), + ...(typeof params.message === 'string' ? { message: params.message } : {}), + ...(typeof params.code === 'string' ? { code: params.code } : {}) + } + } + } + return { + kind: frame.kind, + params: typeof params.streamId === 'number' ? { streamId: params.streamId } : {} + } +} + +function retainedStreamFrameBytes(frame: BufferedStreamFrame): number { + let bytes = 0 + for (const value of Object.values(frame.params)) { + if (typeof value === 'string') { + bytes += Buffer.byteLength(value, 'utf-8') + } + } + return bytes +} + +export class SshStreamAssemblyBudget { + private retained = 0 + + constructor(readonly maxBytes: number) {} + + reserve(bytes: number): (() => void) | null { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.maxBytes - this.retained) { + return null + } + this.retained += bytes + let released = false + return () => { + if (released) { + return + } + released = true + this.retained -= bytes + } + } + + get retainedBytes(): number { + return this.retained + } +} + +export const defaultSshStreamAssemblyBudget = new SshStreamAssemblyBudget( + MAX_SSH_STREAM_ASSEMBLY_BYTES +) + +export function base64EncodedLength(rawBytes: number): number { + return Math.ceil(rawBytes / 3) * 4 +} + +export function createStreamInactivityDeadline( + timeoutMs: number, + onTimeout: () => void +): { reset: () => void; clear: () => void } { + let timer: ReturnType | null = null + const clear = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + return { + reset: () => { + clear() + timer = setTimeout(onTimeout, timeoutMs) + timer.unref?.() + }, + clear + } +} diff --git a/src/main/ssh/ssh-windows-log-tail-command.test.ts b/src/main/ssh/ssh-windows-log-tail-command.test.ts new file mode 100644 index 00000000000..ea3b9946df4 --- /dev/null +++ b/src/main/ssh/ssh-windows-log-tail-command.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + windowsRelayTailLogCommand, + WINDOWS_RELAY_LOG_TAIL_MAX_BYTES +} from './ssh-windows-log-tail-command' + +function decodePowerShellCommand(command: string): string { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' +} + +describe('Windows relay log tail command', () => { + it('reads a fixed byte tail without line materialization', () => { + const script = decodePowerShellCommand( + windowsRelayTailLogCommand('C:/Users/u/relay.log', 'C:/Users/u/relay.err.log') + ) + + expect(script).toContain( + `[Math]::Min([long]${WINDOWS_RELAY_LOG_TAIL_MAX_BYTES}, $stream.Length)` + ) + expect(script).toContain('$stream.Seek(-[long]$readLength') + expect(script).toContain('$stream.Read($buffer, $offset, $readLength - $offset)') + expect(script).toContain('[System.IO.FileShare]::ReadWrite') + expect(script).toContain('--- stderr ---') + expect(script).not.toContain('Get-Content') + expect(script).not.toContain('-Tail 20') + }) +}) diff --git a/src/main/ssh/ssh-windows-log-tail-command.ts b/src/main/ssh/ssh-windows-log-tail-command.ts new file mode 100644 index 00000000000..3d447e3f895 --- /dev/null +++ b/src/main/ssh/ssh-windows-log-tail-command.ts @@ -0,0 +1,31 @@ +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' + +export const WINDOWS_RELAY_LOG_TAIL_MAX_BYTES = 64 * 1024 + +export function windowsRelayTailLogCommand(logFile: string, errFile: string): string { + const script = [ + 'function Read-OrcaLogTail {', + 'param([string]$Path, [string]$Missing)', + 'if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $Missing }', + '$stream = $null', + 'try {', + '$stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)', + `$readLength = [int][Math]::Min([long]${WINDOWS_RELAY_LOG_TAIL_MAX_BYTES}, $stream.Length)`, + '$buffer = New-Object byte[] $readLength', + '$null = $stream.Seek(-[long]$readLength, [System.IO.SeekOrigin]::End)', + '$offset = 0', + 'while ($offset -lt $readLength) {', + '$bytesRead = $stream.Read($buffer, $offset, $readLength - $offset)', + 'if ($bytesRead -eq 0) { break }', + '$offset += $bytesRead', + '}', + 'return [System.Text.Encoding]::UTF8.GetString($buffer, 0, $offset)', + "} catch { return '(unable to read log)' }", + 'finally { if ($null -ne $stream) { $stream.Dispose() } }', + '}', + `[Console]::Out.Write((Read-OrcaLogTail ${powerShellLiteral(logFile)} '(no stdout log)'))`, + '[Console]::Out.Write("`n--- stderr ---`n")', + `[Console]::Out.Write((Read-OrcaLogTail ${powerShellLiteral(errFile)} '(no stderr log)'))` + ].join('\n') + return powerShellCommand(script) +} diff --git a/src/main/ssh/system-ssh-file-transfer.ts b/src/main/ssh/system-ssh-file-transfer.ts index 72cb4425d11..eba59005d42 100644 --- a/src/main/ssh/system-ssh-file-transfer.ts +++ b/src/main/ssh/system-ssh-file-transfer.ts @@ -1,9 +1,10 @@ import { spawn } from 'node:child_process' import { constants } from 'node:fs' -import { lstat, open, readdir } from 'node:fs/promises' +import { lstat, open, opendir } from 'node:fs/promises' import { join as pathJoin } from 'node:path' import { pipeline } from 'node:stream/promises' import type { SshTarget } from '../../shared/ssh-types' +import { stringifyJsonWithinByteLimit } from '../../shared/memory-safety/node-bounded-json-stringify' import { shellEscape, wrapRemoteCommandForPosixShell } from './ssh-connection-utils' import { findSystemSsh } from './system-ssh-binary' import { @@ -23,6 +24,10 @@ import { type ProcessResult } from './system-ssh-operation-lifecycle' import { writeBufferViaSystemSsh } from './system-ssh-file-binary-transfer' +import { + SshDirectoryTransferBudget, + WINDOWS_SSH_UPLOAD_PACKAGE_MAX_BYTES +} from './ssh-directory-transfer-budget' type SystemSshOperationOptions = SystemSshBuildArgsOptions & { signal?: AbortSignal @@ -113,7 +118,9 @@ async function uploadDirectoryViaSystemSshWindows( localDir, remoteDir, hostPlatform, - options.signal + options.signal, + new SshDirectoryTransferBudget(), + 0 ) await writeWindowsUploadPackageViaSystemSsh(target, entries, options) } @@ -133,26 +140,42 @@ async function collectWindowsUploadEntries( localDir: string, remoteDir: string, hostPlatform: RemoteHostPlatform, - signal: AbortSignal | undefined + signal: AbortSignal | undefined, + budget: SshDirectoryTransferBudget, + depth: number ): Promise { + budget.recordPath(remoteDir, depth, { countEntry: depth !== 0 }) const entries: WindowsUploadEntry[] = [{ kind: 'directory', path: remoteDir }] - const dirEntries = await readdir(localDir, { withFileTypes: true }) - for (const entry of dirEntries) { - throwIfAborted(signal) - const localPath = pathJoin(localDir, entry.name) - const remotePath = joinRemotePath(hostPlatform, remoteDir, entry.name) - const statResult = await lstat(localPath) - if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { - continue + const directory = await opendir(localDir) + try { + for await (const entry of directory) { + throwIfAborted(signal) + const localPath = pathJoin(localDir, entry.name) + const remotePath = joinRemotePath(hostPlatform, remoteDir, entry.name) + const statResult = await lstat(localPath) + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { + continue + } + if (statResult.isDirectory()) { + entries.push( + ...(await collectWindowsUploadEntries( + localPath, + remotePath, + hostPlatform, + signal, + budget, + depth + 1 + )) + ) + continue + } + budget.recordPath(remotePath, depth + 1) + budget.recordFile(statResult.size) + const buffer = await readLocalUploadFile(localPath, statResult) + entries.push({ kind: 'file', path: remotePath, contentsBase64: buffer.toString('base64') }) } - if (statResult.isDirectory()) { - entries.push( - ...(await collectWindowsUploadEntries(localPath, remotePath, hostPlatform, signal)) - ) - continue - } - const buffer = await readLocalUploadFile(localPath, statResult) - entries.push({ kind: 'file', path: remotePath, contentsBase64: buffer.toString('base64') }) + } finally { + await directory.close().catch(() => undefined) } return entries } @@ -163,6 +186,10 @@ async function writeWindowsUploadPackageViaSystemSsh( options: SystemSshOperationOptions ): Promise { throwIfAborted(options.signal) + const serialized = stringifyJsonWithinByteLimit( + entries, + WINDOWS_SSH_UPLOAD_PACKAGE_MAX_BYTES + ).serialized const channel = spawnSystemSshCommand(target, makeWindowsUploadPackageCommand(), { wrapCommand: false, ...getSystemSshBuildArgsFromOperationOptions(options) @@ -173,7 +200,7 @@ async function writeWindowsUploadPackageViaSystemSsh( waitForChannelClose(channel, 'windows relay upload') ) if (!options.signal?.aborted) { - channel.stdin.end(JSON.stringify(entries)) + channel.stdin.end(serialized) } await closePromise } @@ -193,7 +220,20 @@ async function readLocalUploadFile( ) { throw new Error(`File changed during upload: ${localPath}`) } - return await handle.readFile() + const buffer = Buffer.allocUnsafe(openedStat.size) + let offset = 0 + while (offset < buffer.length) { + const result = await handle.read(buffer, offset, buffer.length - offset, offset) + if (result.bytesRead === 0) { + throw new Error(`File changed during upload: ${localPath}`) + } + offset += result.bytesRead + } + const probe = Buffer.allocUnsafe(1) + if ((await handle.read(probe, 0, 1, offset)).bytesRead !== 0) { + throw new Error(`File changed during upload: ${localPath}`) + } + return buffer } finally { await handle.close() } diff --git a/src/main/ssh/system-ssh-forward-process.test.ts b/src/main/ssh/system-ssh-forward-process.test.ts index 4178d0e1796..b5e2fcaa3f5 100644 --- a/src/main/ssh/system-ssh-forward-process.test.ts +++ b/src/main/ssh/system-ssh-forward-process.test.ts @@ -34,6 +34,7 @@ import { waitForSystemSshForwardStop } from './system-ssh-forward-process' import type { SshTarget } from '../../shared/ssh-types' +import { SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES } from './system-ssh-output-tail' const SYSTEM_SSH_PATH = process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : '/usr/bin/ssh' @@ -247,6 +248,22 @@ describe('system SSH forward process', () => { await expect(pending).rejects.toThrow('bind: Address already in use') }) + it('keeps only a bounded stderr tail while waiting for forward startup', async () => { + vi.useFakeTimers() + const child = createFakeProcess() + connectMock.mockReturnValue(createFakeSocket()) + + const pending = waitForSystemSshForwardStartup(child as never, 3000) + child.stderr.emit( + 'data', + Buffer.from(`HEAD${'x'.repeat(SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES)}TAIL`) + ) + child.emit('exit', 255) + + await expect(pending).rejects.toThrow('TAIL') + await expect(pending).rejects.not.toThrow('HEAD') + }) + it('rejects startup when the ssh process emits an error', async () => { vi.useFakeTimers() const child = createFakeProcess() diff --git a/src/main/ssh/system-ssh-forward-process.ts b/src/main/ssh/system-ssh-forward-process.ts index 13486101bfd..65a4732d426 100644 --- a/src/main/ssh/system-ssh-forward-process.ts +++ b/src/main/ssh/system-ssh-forward-process.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from 'node:child_process' import { connect, createServer } from 'node:net' import { buildSshArgs, findSystemSsh, type SystemSshBuildArgsOptions } from './ssh-system-fallback' import type { SshTarget } from '../../shared/ssh-types' +import { SystemSshOutputTail } from './system-ssh-output-tail' export const SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS = 750 export const SYSTEM_SSH_FORWARD_LISTENER_PROBE_INTERVAL_MS = 50 @@ -103,7 +104,7 @@ export function waitForSystemSshForwardStartup( localPort: number ): Promise { return new Promise((resolve, reject) => { - let stderr = '' + const stderr = new SystemSshOutputTail() let settled = false let probeTimer: ReturnType | null = null let graceTimer: ReturnType | null = null @@ -127,13 +128,13 @@ export function waitForSystemSshForwardStartup( callback() } const onStderr = (chunk: Buffer): void => { - stderr += chunk.toString('utf-8') + stderr.push(chunk) } const onError = (error: Error): void => { finish(() => reject(error)) } const onExit = (code: number | null): void => { - finish(() => reject(systemSshForwardError(code, stderr))) + finish(() => reject(systemSshForwardError(code, stderr.toString()))) } const scheduleProbe = (): void => { probeTimer = setTimeout(() => { diff --git a/src/main/ssh/system-ssh-operation-lifecycle.test.ts b/src/main/ssh/system-ssh-operation-lifecycle.test.ts new file mode 100644 index 00000000000..bda9f6c9206 --- /dev/null +++ b/src/main/ssh/system-ssh-operation-lifecycle.test.ts @@ -0,0 +1,50 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it } from 'vitest' +import { waitForChannelClose, waitForProcess } from './system-ssh-operation-lifecycle' +import { SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES } from './system-ssh-output-tail' + +function createProcess(): EventEmitter & { + stderr: EventEmitter +} { + return Object.assign(new EventEmitter(), { stderr: new EventEmitter() }) +} + +function createChannel(): EventEmitter & { + stderr: EventEmitter +} { + return Object.assign(new EventEmitter(), { stderr: new EventEmitter() }) +} + +describe('system SSH operation output capture', () => { + it('returns a bounded stderr tail for successful child processes', async () => { + const process = createProcess() + const result = waitForProcess(process as never, 'test process') + + process.stderr.emit( + 'data', + Buffer.from(`HEAD${'x'.repeat(SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES)}TAIL`) + ) + process.emit('close', 0) + + await expect(result).resolves.toMatchObject({ + label: 'test process', + stderr: expect.stringContaining('TAIL') + }) + expect((await result).stderr).not.toContain('HEAD') + }) + + it('uses the bounded stderr tail in channel failure diagnostics', async () => { + const channel = createChannel() + const result = waitForChannelClose(channel as never, 'test channel') + + channel.stderr.emit( + 'data', + Buffer.from(`HEAD${'x'.repeat(SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES)}TAIL`) + ) + channel.emit('close', 1, null) + + await expect(result).rejects.toThrow('TAIL') + await expect(result).rejects.not.toThrow('HEAD') + expect(channel.stderr.listenerCount('data')).toBe(0) + }) +}) diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index c9c4424325b..864669ad326 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -1,5 +1,6 @@ import type { ChildProcess } from 'node:child_process' import type { SystemSshCommandChannel } from './system-ssh-command' +import { SystemSshOutputTail } from './system-ssh-output-tail' export type ProcessResult = { label: string; stderr: string } @@ -8,7 +9,7 @@ export function waitForChannelClose( label: string ): Promise { return new Promise((resolve, reject) => { - let stderr = '' + const stderr = new SystemSshOutputTail() const cleanup = (): void => { channel.stderr.off('data', onStderrData) channel.off('error', onError) @@ -19,7 +20,7 @@ export function waitForChannelClose( fn(val as never) } const onStderrData = (data: Buffer): void => { - stderr += data.toString('utf-8') + stderr.push(data) } const onError = (err: Error): void => { settle(reject, err) @@ -27,7 +28,7 @@ export function waitForChannelClose( const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { if (code !== 0) { const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` - settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`)) + settle(reject, new Error(`${label} failed (${detail}): ${stderr.toString().trim()}`)) return } settle(resolve) @@ -41,7 +42,7 @@ export function waitForChannelClose( export function waitForProcess(proc: ChildProcess, label: string): Promise { return new Promise((resolve, reject) => { - let stderr = '' + const stderr = new SystemSshOutputTail() const cleanup = (): void => { proc.stderr?.off('data', onStderrData) proc.off('error', onError) @@ -52,17 +53,17 @@ export function waitForProcess(proc: ChildProcess, label: string): Promise { - stderr += data.toString('utf-8') + stderr.push(data) } const onError = (err: Error): void => { settle(reject, err) } const onClose = (code: number | null): void => { if (code !== 0) { - settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.trim()}`)) + settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.toString().trim()}`)) return } - settle(resolve, { label, stderr }) + settle(resolve, { label, stderr: stderr.toString() }) } proc.stderr?.on('data', onStderrData) diff --git a/src/main/ssh/system-ssh-output-tail.test.ts b/src/main/ssh/system-ssh-output-tail.test.ts new file mode 100644 index 00000000000..d47332eaad7 --- /dev/null +++ b/src/main/ssh/system-ssh-output-tail.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { SystemSshOutputTail } from './system-ssh-output-tail' + +describe('SystemSshOutputTail', () => { + it('preserves ordinary output exactly', () => { + const output = new SystemSshOutputTail(16) + output.push('first ') + output.push(Buffer.from('second')) + + expect(output.toString()).toBe('first second') + }) + + it('keeps only the newest bytes after many chunks', () => { + const output = new SystemSshOutputTail(8) + for (const chunk of ['123', '456', '789', 'tail']) { + output.push(chunk) + } + + expect(output.toString()).toBe('[earlier system SSH output truncated]\n6789tail') + }) + + it('copies a bounded slice instead of retaining a large chunk backing buffer', () => { + const output = new SystemSshOutputTail(4) + const large = Buffer.from('discard-prefix-tail') + output.push(large) + large.fill(0) + + expect(output.toString()).toBe('[earlier system SSH output truncated]\ntail') + }) +}) diff --git a/src/main/ssh/system-ssh-output-tail.ts b/src/main/ssh/system-ssh-output-tail.ts new file mode 100644 index 00000000000..89fa190f820 --- /dev/null +++ b/src/main/ssh/system-ssh-output-tail.ts @@ -0,0 +1,37 @@ +export const SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES = 64 * 1024 + +const TRUNCATION_NOTICE = '[earlier system SSH output truncated]\n' + +export class SystemSshOutputTail { + private buffer = Buffer.alloc(0) + private truncated = false + + constructor(private readonly maxBytes = SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + throw new RangeError('System SSH output tail limit must be a non-negative safe integer') + } + } + + push(value: Buffer | string): void { + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) + if (chunk.byteLength >= this.maxBytes) { + const hadBufferedOutput = this.buffer.byteLength > 0 + this.buffer = Buffer.from(chunk.subarray(chunk.byteLength - this.maxBytes)) + this.truncated ||= hadBufferedOutput || chunk.byteLength > this.maxBytes + return + } + const combinedBytes = this.buffer.byteLength + chunk.byteLength + if (combinedBytes <= this.maxBytes) { + this.buffer = Buffer.concat([this.buffer, chunk], combinedBytes) + return + } + const discardBytes = combinedBytes - this.maxBytes + this.buffer = Buffer.concat([this.buffer.subarray(discardBytes), chunk], this.maxBytes) + this.truncated = true + } + + toString(): string { + const text = this.buffer.toString('utf8') + return this.truncated ? `${TRUNCATION_NOTICE}${text}` : text + } +} diff --git a/src/main/ssh/system-ssh-port-forward-provider.ts b/src/main/ssh/system-ssh-port-forward-provider.ts index e2b53abab7b..5570628b69c 100644 --- a/src/main/ssh/system-ssh-port-forward-provider.ts +++ b/src/main/ssh/system-ssh-port-forward-provider.ts @@ -8,6 +8,7 @@ import type { SshPortForwardProvider, StartedPortForward } from './ssh-port-forward-provider' +import { SystemSshOutputTail } from './system-ssh-output-tail' export class SystemSshPortForwardProvider implements SshPortForwardProvider { canHandle(conn: SshConnection): boolean { @@ -33,17 +34,9 @@ export class SystemSshPortForwardProvider implements SshPortForwardProvider { ) await forward.waitForStartup() - // Why: this stderr stays attached for the forward's whole lifetime but is - // only used to build the exit-error detail, so keep a bounded tail — a chatty - // remote sshd could otherwise grow it unbounded on a long-lived forward - // (mirrors MAX_RELAY_STARTUP_BUFFER_BYTES in ssh-relay-deploy-helpers). - const MAX_STDERR_TAIL_BYTES = 64 * 1024 - let stderr = '' + const stderr = new SystemSshOutputTail() const onStderr = (chunk: Buffer): void => { - stderr += chunk.toString('utf-8') - if (stderr.length > MAX_STDERR_TAIL_BYTES) { - stderr = stderr.slice(-MAX_STDERR_TAIL_BYTES) - } + stderr.push(chunk) } forward.process.stderr?.on('data', onStderr) @@ -60,7 +53,7 @@ export class SystemSshPortForwardProvider implements SshPortForwardProvider { forward.process.stderr?.off('data', onStderr) options.onUnexpectedClose?.(entry, { kind: 'unexpected-exit', - detail: systemSshForwardError(code, stderr).message + detail: systemSshForwardError(code, stderr.toString()).message }) }) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 7d582880f28..1304115a254 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,7 +1,8 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCA_PERSISTED_STATE_MAX_BYTES } from '../../shared/persisted-state-file-bounds' vi.mock('electron', () => { const paths = new Map([['appData', '/tmp/app-data']]) @@ -302,6 +303,14 @@ describe('configureElectronNetworkCompatibility', () => { expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) }) + it('ignores an oversized persisted state file before early startup parsing', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const userDataPath = createUserDataDir({ electronHttp1CompatibilityMode: true }) + truncateSync(join(userDataPath, 'orca-data.json'), ORCA_PERSISTED_STATE_MAX_BYTES + 1) + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) + it('lets the environment override force compatibility on', async () => { const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') @@ -398,6 +407,29 @@ describe('enableMainProcessGpuFeatures', () => { expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('max-active-webgl-contexts', '128') }) + it('disables Skia Graphite only on macOS without disabling hardware acceleration', async () => { + const { app } = await import('electron') + const { enableMainProcessGpuFeatures } = await import('./configure-process') + + delete process.env.ORCA_E2E_USER_DATA_DIR + vi.mocked(app.disableHardwareAcceleration).mockClear() + + for (const platform of ['darwin', 'linux', 'win32'] as const) { + setPlatform(platform) + vi.mocked(app.commandLine.appendSwitch).mockClear() + + enableMainProcessGpuFeatures() + + if (platform === 'darwin') { + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-skia-graphite') + } else { + expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('disable-skia-graphite') + } + } + + expect(app.disableHardwareAcceleration).not.toHaveBeenCalled() + }) + it('disables the GPU sandbox on Linux Wayland without disabling acceleration', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 40ec5e2efbe..77e072e0510 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -1,9 +1,10 @@ import { app } from 'electron' -import { existsSync, mkdirSync, readFileSync } from 'node:fs' +import { existsSync, mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { getVersionManagerBinPaths } from '../codex-cli/command' import { getMainE2EConfig } from '../e2e-config' +import { readPersistedStateJsonFileSync } from '../../shared/persisted-state-file-bounds' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 const HTTP1_COMPATIBILITY_ENV_VAR = 'ORCA_DISABLE_HTTP2' @@ -37,9 +38,9 @@ function readPersistedHttp1CompatibilityMode(userDataPath: string): boolean { } try { - const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as { + const { value: parsed } = readPersistedStateJsonFileSync<{ settings?: { electronHttp1CompatibilityMode?: unknown } - } + }>(dataFile) return parsed.settings?.electronHttp1CompatibilityMode === true } catch { return false @@ -262,6 +263,13 @@ export function enableMainProcessGpuFeatures(): void { return } + if (process.platform === 'darwin') { + // Why: Graphite can strand corrupt Metal tiles after idle; Ganesh preserves GPU compositing without the stale surface. + // Reached on every macOS launch only because GPU fallback skips this function and is win32-only; if fallback ever + // reaches macOS this must move out of this path or Macs silently lose the fix. + app.commandLine.appendSwitch('disable-skia-graphite') + } + // Why: Blink evicts the oldest WebGL context past 16/renderer and each terminal pane holds one, silently downgrading panes to DOM. // 128 raises the ceiling for real layouts while staying bounded so context leaks still surface. app.commandLine.appendSwitch('max-active-webgl-contexts', '128') diff --git a/src/main/startup/ensure-virtual-display.test.ts b/src/main/startup/ensure-virtual-display.test.ts index f66d79fefea..c53ec5a75f7 100644 --- a/src/main/startup/ensure-virtual-display.test.ts +++ b/src/main/startup/ensure-virtual-display.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { spawnMock, spawnSyncMock, existsSyncMock, readFileSyncMock, rmSyncMock, appMock } = +const { spawnMock, spawnSyncMock, existsSyncMock, boundedReadMock, rmSyncMock, appMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), spawnSyncMock: vi.fn(), existsSyncMock: vi.fn(), - readFileSyncMock: vi.fn(), + boundedReadMock: vi.fn(), rmSyncMock: vi.fn(), appMock: { disableHardwareAcceleration: vi.fn(), @@ -17,9 +17,11 @@ const { spawnMock, spawnSyncMock, existsSyncMock, readFileSyncMock, rmSyncMock, vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: spawnSyncMock })) vi.mock('fs', () => ({ existsSync: existsSyncMock, - readFileSync: readFileSyncMock, rmSync: rmSyncMock })) +vi.mock('../../shared/memory-safety/node-bounded-file-reader', () => ({ + readNodeFileSyncWithinLimit: boundedReadMock +})) vi.mock('electron', () => ({ app: appMock })) const ORIGINAL_PLATFORM = process.platform @@ -34,7 +36,7 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { spawnMock.mockReset() spawnSyncMock.mockReset() existsSyncMock.mockReset() - readFileSyncMock.mockReset() + boundedReadMock.mockReset() rmSyncMock.mockReset() appMock.disableHardwareAcceleration.mockReset() appMock.commandLine.appendSwitch.mockReset() @@ -115,7 +117,7 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { setPlatform('linux') spawnSyncMock.mockReturnValue({ status: 0 }) existsSyncMock.mockReturnValue(true) // :99 socket + lock present - readFileSyncMock.mockReturnValue('4321\n') // lock holds a PID + boundedReadMock.mockReturnValue({ buffer: Buffer.from('4321\n') }) // lock holds a PID const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never) // PID alive const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') @@ -131,7 +133,7 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { setPlatform('linux') spawnSyncMock.mockReturnValue({ status: 0 }) existsSyncMock.mockReturnValue(true) // orphan socket + lock present - readFileSyncMock.mockReturnValue('9999\n') + boundedReadMock.mockReturnValue({ buffer: Buffer.from('9999\n') }) // PID is gone: process.kill throws ESRCH. const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('ESRCH') diff --git a/src/main/startup/ensure-virtual-display.ts b/src/main/startup/ensure-virtual-display.ts index a4adfed6113..655a9a3d777 100644 --- a/src/main/startup/ensure-virtual-display.ts +++ b/src/main/startup/ensure-virtual-display.ts @@ -1,6 +1,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process' -import { existsSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, rmSync } from 'node:fs' import { app } from 'electron' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' // Why: headless `orca serve` backs browser panes with offscreen BrowserWindows. // On Linux, Electron has no display platform without an X server and segfaults @@ -12,6 +13,7 @@ const XVFB_STARTUP_TIMEOUT_MS = 5_000 const XVFB_POLL_INTERVAL_MS = 50 const VIRTUAL_DISPLAY_NUMBER = 99 const VIRTUAL_DISPLAY = `:${VIRTUAL_DISPLAY_NUMBER}` +export const X_DISPLAY_LOCK_MAX_BYTES = 64 let xvfbProcess: ChildProcess | null = null @@ -41,13 +43,8 @@ function isDisplayServerAlive(displayNumber: number): boolean { // No lock means no server claimed this display; the bare socket is stale. return false } - let pid: number - try { - pid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10) - } catch { - return false - } - if (!Number.isInteger(pid) || pid <= 0) { + const pid = readXDisplayLockPid(lockPath) + if (pid === null) { return false } try { @@ -59,6 +56,18 @@ function isDisplayServerAlive(displayNumber: number): boolean { } } +export function readXDisplayLockPid(lockPath: string): number | null { + try { + const raw = readNodeFileSyncWithinLimit(lockPath, X_DISPLAY_LOCK_MAX_BYTES) + .buffer.toString('utf8') + .trim() + const pid = Number.parseInt(raw, 10) + return Number.isInteger(pid) && pid > 0 ? pid : null + } catch { + return null + } +} + function removeStaleDisplayArtifacts(displayNumber: number): void { for (const path of [xDisplayLockPath(displayNumber), xvfbSocketPath(displayNumber)]) { try { diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index 7ff6ee2ae81..e085506a597 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -5,6 +5,7 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { _resetHydrateShellPathCache, hydrateShellPath, + MAX_SHELL_PATH_STDOUT_BYTES, mergePathSegments, type HydrationResult } from './hydrate-shell-path' @@ -157,6 +158,45 @@ describe('hydrateShellPath', () => { vi.useRealTimers() } }) + + it('kills a login shell whose stdout exceeds the PATH capture limit', async () => { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + + const resultPromise = hydrateShellPath({ shellOverride: '/bin/zsh', force: true }) + proc.stdout.emit('data', Buffer.alloc(1024 * 1024 + 1, 0x78)) + + await expect(resultPromise).resolves.toEqual({ + segments: [], + ok: false, + failureReason: 'empty_path' + }) + expect(proc.kill).toHaveBeenCalledWith('SIGKILL') + expect(proc.stdout.listenerCount('data')).toBe(0) + expect(proc.listenerCount('error')).toBe(0) + expect(proc.listenerCount('close')).toBe(0) + }) + + it('captures PATH after tens of thousands of tiny stdout chunks', async () => { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + const delimiterText = '__ORCA_SHELL_PATH__' + const output = Buffer.from( + `${'x'.repeat(MAX_SHELL_PATH_STDOUT_BYTES - 128)}${delimiterText}/bin${delimiterText}` + ) + + const resultPromise = hydrateShellPath({ shellOverride: '/bin/zsh', force: true }) + for (let offset = 0; offset < output.byteLength; offset += 16) { + proc.stdout.emit('data', output.subarray(offset, offset + 16)) + } + proc.emit('close') + + await expect(resultPromise).resolves.toEqual({ + segments: ['/bin'], + ok: true, + failureReason: 'none' + }) + }) }) describe('mergePathSegments', () => { diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 9411b0437d3..69bba7c0ce8 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process' import { delimiter } from 'node:path' import type { ShellHydrationFailureReason } from '../../shared/types' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' // Why: GUI-launched Electron on macOS/Linux inherits a minimal PATH from launchd // that does not include dirs appended by the user's shell rc files (~/.zshrc, @@ -16,6 +17,7 @@ import type { ShellHydrationFailureReason } from '../../shared/types' const DELIMITER = '__ORCA_SHELL_PATH__' const SPAWN_TIMEOUT_MS = 5000 +export const MAX_SHELL_PATH_STDOUT_BYTES = 1024 * 1024 // ANSI escape sequences can leak into the captured output when the user's rc // files print banners or set colored prompts. Strip them before parsing. @@ -85,7 +87,7 @@ function spawnShellAndReadPath(shell: string): Promise { // and .bashrc/.zshrc are sourced — matches what `which` in Terminal sees. const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` let finished = false - let stdout = '' + const stdout = new GrowingByteBuffer() let timer: ReturnType | null = null const child = spawn(shell, ['-ilc', command], { @@ -129,7 +131,15 @@ function spawnShellAndReadPath(shell: string): Promise { }, SPAWN_TIMEOUT_MS) const onStdoutData = (chunk: Buffer): void => { - stdout += chunk.toString('utf8') + if (chunk.byteLength > MAX_SHELL_PATH_STDOUT_BYTES - stdout.byteLength) { + try { + child.kill('SIGKILL') + } catch {} + stdout.clear() + finish({ segments: [], ok: false, failureReason: 'empty_path' }) + return + } + stdout.append(chunk) } const onError = (): void => { @@ -137,7 +147,7 @@ function spawnShellAndReadPath(shell: string): Promise { } const onClose = (): void => { - const segments = parseCapturedPath(stdout) + const segments = parseCapturedPath(stdout.toString('utf8')) if (segments.length === 0) { finish({ segments: [], ok: false, failureReason: 'empty_path' }) return diff --git a/src/main/stats/collector.ts b/src/main/stats/collector.ts index 508aa0a172a..d944e0a1d9c 100644 --- a/src/main/stats/collector.ts +++ b/src/main/stats/collector.ts @@ -1,17 +1,25 @@ import { app } from 'electron' -import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs' +import { writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs' import { writeFile, mkdir, rm } from 'node:fs/promises' import { join, dirname } from 'node:path' import type { StatsSummary } from '../../shared/types' +import { readNodeFileSyncWithinLimit } from '../../shared/memory-safety/node-bounded-file-reader' +import { stringifyJsonWithinByteLimit } from '../../shared/memory-safety/node-bounded-json-stringify' +import { measureUtf8ByteLength } from '../../shared/memory-safety/utf8-byte-limits' import type { StatsEvent, StatsAggregates, StatsFile } from './types' +import { StatsAggregateTracker } from './stats-aggregate-tracker' +import { + createDefaultStatsFile, + parseLoadedStatsFile, + STATS_FILE_MAX_BYTES, + STATS_LIVE_AGENT_ID_MAX_BYTES, + STATS_LIVE_AGENT_MAX_ENTRIES, + STATS_LIVE_AGENT_MAX_RETAINED_ID_BYTES, + STATS_SCHEMA_VERSION, + StatsEventLog +} from './stats-retention' -const STATS_SCHEMA_VERSION = 1 -const MAX_EVENTS = 10_000 -// Why: countedPRs is a deduplication registry that grows with every PR created -// through Orca. Without a cap, a heavily-used instance accumulates thousands of -// URL strings across months. 2000 entries is about 6-12 months of active use -// for a power user, and at ~50 chars per URL the overhead is ~100KB max. -const MAX_COUNTED_PRS = 2_000 +export * from './stats-retention' // Why 5s instead of the main store's 300ms: stat events are infrequent // (a few per session) and not latency-sensitive for the UI. const DEBOUNCE_MS = 5_000 @@ -33,28 +41,11 @@ function getStatsFile(): string { return _statsFile } -function getDefaultAggregates(): StatsAggregates { - return { - totalAgentsSpawned: 0, - totalPRsCreated: 0, - totalAgentTimeMs: 0, - countedPRs: [], - firstEventAt: null - } -} - -function getDefaultStatsFile(): StatsFile { - return { - schemaVersion: STATS_SCHEMA_VERSION, - events: [], - aggregates: getDefaultAggregates() - } -} - export class StatsCollector { - private events: StatsEvent[] - private aggregates: StatsAggregates + private eventLog: StatsEventLog + private aggregateTracker: StatsAggregateTracker private liveAgents = new Map() // ptyId → startTimestamp + private liveAgentIdBytes = 0 private writeTimer: ReturnType | null = null // Monotonic id stamped on each prepared payload; the highest committed one // wins so a slow in-flight async write can't clobber a newer sync flush. @@ -67,8 +58,16 @@ export class StatsCollector { constructor() { const data = this.load() - this.events = data.events - this.aggregates = data.aggregates + this.eventLog = new StatsEventLog(data.events) + this.aggregateTracker = new StatsAggregateTracker(data.aggregates) + } + + private get aggregates(): StatsAggregates { + return this.aggregateTracker.aggregates + } + + private get events(): StatsEvent[] { + return this.eventLog.events } onAgentStarted(listener: (totalAgentsSpawned: number) => void): () => void { @@ -85,15 +84,36 @@ export class StatsCollector { // ── Recording ────────────────────────────────────────────────────── record(event: StatsEvent): void { - this.events.push(event) - this.updateAggregates(event) + this.eventLog.retain(event) + this.aggregateTracker.record(event, this.agentStartListeners) this.scheduleSave() } // ── Agent lifecycle (called by AgentDetector) ───────────────────── onAgentStart(ptyId: string, at: number, repoId?: string, worktreeId?: string): void { - this.liveAgents.set(ptyId, at) + const idMeasurement = measureUtf8ByteLength(ptyId, { + stopAfterBytes: STATS_LIVE_AGENT_ID_MAX_BYTES + }) + if (!idMeasurement.exceededLimit) { + if (!this.liveAgents.has(ptyId)) { + while ( + this.liveAgents.size >= STATS_LIVE_AGENT_MAX_ENTRIES || + this.liveAgentIdBytes + idMeasurement.byteLength > STATS_LIVE_AGENT_MAX_RETAINED_ID_BYTES + ) { + const oldest = this.liveAgents.entries().next() + if (oldest.done) { + break + } + const [oldestPtyId, oldestStartAt] = oldest.value + this.liveAgents.delete(oldestPtyId) + this.liveAgentIdBytes -= measureUtf8ByteLength(oldestPtyId).byteLength + this.recordAgentStop(oldestPtyId, oldestStartAt, at) + } + this.liveAgentIdBytes += idMeasurement.byteLength + } + this.liveAgents.set(ptyId, at) + } this.record({ type: 'agent_start', at, @@ -109,6 +129,11 @@ export class StatsCollector { return } this.liveAgents.delete(ptyId) + this.liveAgentIdBytes -= measureUtf8ByteLength(ptyId).byteLength + this.recordAgentStop(ptyId, startAt, at) + } + + private recordAgentStop(ptyId: string, startAt: number, at: number): void { const durationMs = Math.max(0, at - startAt) this.aggregates.totalAgentTimeMs += durationMs this.record({ @@ -127,12 +152,7 @@ export class StatsCollector { // ── Query ───────────────────────────────────────────────────────── getSummary(): StatsSummary { - return { - totalAgentsSpawned: this.aggregates.totalAgentsSpawned, - totalPRsCreated: this.aggregates.totalPRsCreated, - totalAgentTimeMs: this.aggregates.totalAgentTimeMs, - firstEventAt: this.aggregates.firstEventAt - } + return this.aggregateTracker.getSummary() } // ── Shutdown flush ──────────────────────────────────────────────── @@ -163,17 +183,10 @@ export class StatsCollector { try { const statsFile = getStatsFile() if (existsSync(statsFile)) { - const raw = readFileSync(statsFile, 'utf-8') - const parsed = JSON.parse(raw) as StatsFile - // Merge with defaults for forward compatibility - return { - ...getDefaultStatsFile(), - ...parsed, - aggregates: { - ...getDefaultAggregates(), - ...parsed.aggregates - } - } + const raw = readNodeFileSyncWithinLimit(statsFile, STATS_FILE_MAX_BYTES).buffer.toString( + 'utf8' + ) + return parseLoadedStatsFile(raw) } } catch (err) { // Why "start fresh" instead of crashing: lifetime aggregates are lost @@ -182,47 +195,7 @@ export class StatsCollector { // disk so it can be inspected for debugging. console.error('[stats] Failed to load stats, starting fresh:', err) } - return getDefaultStatsFile() - } - - private updateAggregates(event: StatsEvent): void { - if (this.aggregates.firstEventAt === null) { - this.aggregates.firstEventAt = event.at - } - - switch (event.type) { - case 'agent_start': - this.aggregates.totalAgentsSpawned++ - // Why: notify listeners synchronously AFTER increment so observers - // see the post-increment count. Listener errors are swallowed to - // keep stat recording robust — a buggy listener must not lose the - // event from the on-disk log. - for (const listener of this.agentStartListeners) { - try { - listener(this.aggregates.totalAgentsSpawned) - } catch (err) { - console.error('[stats] agent-start listener threw:', err) - } - } - break - case 'pr_created': - this.aggregates.totalPRsCreated++ - if (event.meta?.prUrl) { - this.aggregates.countedPRs.push(String(event.meta.prUrl)) - // Why: trim oldest entries so the dedup array does not grow without - // bound. The aggregate totalPRsCreated counter remains accurate; only - // the dedup lookup for very old PRs is lost, which is acceptable - // since PRs that old would never be re-counted in practice. - if (this.aggregates.countedPRs.length > MAX_COUNTED_PRS) { - this.aggregates.countedPRs = this.aggregates.countedPRs.slice(-MAX_COUNTED_PRS) - } - } - break - // agent_stop duration is handled directly in onAgentStop() to avoid - // double-counting — the duration is added to totalAgentTimeMs there. - case 'agent_stop': - break - } + return createDefaultStatsFile() } private scheduleSave(): void { @@ -250,9 +223,9 @@ export class StatsCollector { } } - // Serialize the current state and pick a unique temp path. Trimming mutates - // in-memory state and JSON.stringify must see a consistent snapshot, so both - // writers call this synchronously before any await to avoid a torn snapshot. + // Serialize the current state and pick a unique temp path. JSON.stringify + // must see a consistent snapshot, so both writers call this synchronously + // before any await to avoid a torn snapshot. // The monotonic generation lets a later write veto an earlier, still-in-flight // one so a stale rename can never win (see writeToDiskAsync). private prepareWritePayload(): { @@ -263,11 +236,6 @@ export class StatsCollector { } { const statsFile = getStatsFile() - // Trim events to bounded size before writing - if (this.events.length > MAX_EVENTS) { - this.events = this.events.slice(-MAX_EVENTS) - } - const data: StatsFile = { schemaVersion: STATS_SCHEMA_VERSION, events: this.events, @@ -278,7 +246,8 @@ export class StatsCollector { // Unique temp file so the async debounced writer and the sync shutdown // flush never write the same temp path (same pattern as persistence.ts). const tmpFile = `${statsFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - return { statsFile, tmpFile, json: JSON.stringify(data), generation } + const json = stringifyJsonWithinByteLimit(data, STATS_FILE_MAX_BYTES).serialized + return { statsFile, tmpFile, json, generation } } private writeToDiskSync(): void { diff --git a/src/main/stats/stats-aggregate-tracker.ts b/src/main/stats/stats-aggregate-tracker.ts new file mode 100644 index 00000000000..3ed168571ad --- /dev/null +++ b/src/main/stats/stats-aggregate-tracker.ts @@ -0,0 +1,71 @@ +import type { StatsSummary } from '../../shared/types' +import type { StatsAggregates, StatsEvent } from './types' +import { + jsonByteLengthWithinLimit, + STATS_COUNTED_PR_MAX_ENTRIES, + STATS_COUNTED_PR_MAX_RETAINED_BYTES, + STATS_COUNTED_PR_URL_MAX_BYTES +} from './stats-retention' + +export class StatsAggregateTracker { + private countedPrRetainedBytes: number + + constructor(readonly aggregates: StatsAggregates) { + this.countedPrRetainedBytes = aggregates.countedPRs.reduce( + (total, value) => + total + (jsonByteLengthWithinLimit(value, STATS_COUNTED_PR_URL_MAX_BYTES) ?? 0), + 0 + ) + } + + record(event: StatsEvent, agentStartListeners: ((total: number) => void)[]): void { + if (this.aggregates.firstEventAt === null) { + this.aggregates.firstEventAt = event.at + } + if (event.type === 'agent_start') { + this.aggregates.totalAgentsSpawned++ + for (const listener of agentStartListeners) { + try { + listener(this.aggregates.totalAgentsSpawned) + } catch (err) { + console.error('[stats] agent-start listener threw:', err) + } + } + return + } + if (event.type !== 'pr_created') { + return + } + this.aggregates.totalPRsCreated++ + if (!event.meta?.prUrl) { + return + } + const prUrl = String(event.meta.prUrl) + const prUrlBytes = jsonByteLengthWithinLimit(prUrl, STATS_COUNTED_PR_URL_MAX_BYTES) + if (prUrlBytes === null) { + return + } + this.aggregates.countedPRs.push(prUrl) + this.countedPrRetainedBytes += prUrlBytes + while ( + this.aggregates.countedPRs.length > STATS_COUNTED_PR_MAX_ENTRIES || + this.countedPrRetainedBytes > STATS_COUNTED_PR_MAX_RETAINED_BYTES + ) { + const oldestPrUrl = this.aggregates.countedPRs.shift() + if (oldestPrUrl === undefined) { + break + } + this.countedPrRetainedBytes -= + jsonByteLengthWithinLimit(oldestPrUrl, STATS_COUNTED_PR_URL_MAX_BYTES) ?? 0 + } + } + + getSummary(): StatsSummary { + return { + totalAgentsSpawned: this.aggregates.totalAgentsSpawned, + totalPRsCreated: this.aggregates.totalPRsCreated, + totalAgentTimeMs: this.aggregates.totalAgentTimeMs, + firstEventAt: this.aggregates.firstEventAt + } + } +} diff --git a/src/main/stats/stats-file-json-admission.test.ts b/src/main/stats/stats-file-json-admission.test.ts new file mode 100644 index 00000000000..3b4d3bc9f06 --- /dev/null +++ b/src/main/stats/stats-file-json-admission.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { parseLoadedStatsFile } from './stats-retention' + +describe('stats file JSON admission', () => { + it('preserves ordinary stats under the structure limits', () => { + expect( + parseLoadedStatsFile('{"schemaVersion":1,"events":[],"aggregates":{}}', { + structuralTokens: 11, + nestingDepth: 2 + }) + ).toMatchObject({ schemaVersion: 1, events: [] }) + }) + + it('rejects structural amplification before parsing stats', () => { + expect(() => + parseLoadedStatsFile('{"events":[{},{}]}', { + structuralTokens: 7, + nestingDepth: 3 + }) + ).toThrow('JSON structure') + }) +}) diff --git a/src/main/stats/stats-retention.ts b/src/main/stats/stats-retention.ts new file mode 100644 index 00000000000..e9f97e15659 --- /dev/null +++ b/src/main/stats/stats-retention.ts @@ -0,0 +1,170 @@ +import { stringifyJsonWithinByteLimit } from '../../shared/memory-safety/node-bounded-json-stringify' +import { + assertJsonTextStructureWithinLimits, + type JsonTextStructureLimits +} from '../../shared/memory-safety/json-text-structure-limit' +import type { StatsAggregates, StatsEvent, StatsFile } from './types' + +export const STATS_SCHEMA_VERSION = 1 +export const STATS_FILE_MAX_BYTES = 16 * 1024 * 1024 +export const STATS_EVENT_MAX_ENTRIES = 10_000 +export const STATS_EVENT_MAX_BYTES = 1024 * 1024 +export const STATS_EVENT_MAX_RETAINED_BYTES = 12 * 1024 * 1024 +export const STATS_LIVE_AGENT_MAX_ENTRIES = 4_096 +export const STATS_LIVE_AGENT_ID_MAX_BYTES = 4 * 1024 +export const STATS_LIVE_AGENT_MAX_RETAINED_ID_BYTES = 1024 * 1024 +export const STATS_COUNTED_PR_MAX_ENTRIES = 2_000 +export const STATS_COUNTED_PR_URL_MAX_BYTES = 8 * 1024 +export const STATS_COUNTED_PR_MAX_RETAINED_BYTES = 1024 * 1024 +export const STATS_FILE_JSON_LIMITS: JsonTextStructureLimits = { + structuralTokens: 1_000_000, + nestingDepth: 128 +} + +export function jsonByteLengthWithinLimit(value: unknown, maxBytes: number): number | null { + try { + return stringifyJsonWithinByteLimit(value, maxBytes).byteLength + } catch { + return null + } +} + +function normalizeLoadedEvents(values: unknown[]): StatsEvent[] { + const newest: StatsEvent[] = [] + let retainedBytes = 0 + for ( + let index = values.length - 1; + index >= 0 && newest.length < STATS_EVENT_MAX_ENTRIES; + index-- + ) { + const value = values[index] + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + continue + } + const event = value as StatsEvent + const eventBytes = jsonByteLengthWithinLimit(event, STATS_EVENT_MAX_BYTES) + if (eventBytes === null) { + continue + } + if (retainedBytes + eventBytes > STATS_EVENT_MAX_RETAINED_BYTES) { + break + } + retainedBytes += eventBytes + newest.push(event) + } + return newest.toReversed() +} + +function normalizeCountedPrUrls(values: unknown[]): string[] { + const newest: string[] = [] + let retainedBytes = 0 + for ( + let index = values.length - 1; + index >= 0 && newest.length < STATS_COUNTED_PR_MAX_ENTRIES; + index-- + ) { + const value = values[index] + if (typeof value !== 'string') { + continue + } + const valueBytes = jsonByteLengthWithinLimit(value, STATS_COUNTED_PR_URL_MAX_BYTES) + if (valueBytes === null) { + continue + } + if (retainedBytes + valueBytes > STATS_COUNTED_PR_MAX_RETAINED_BYTES) { + break + } + retainedBytes += valueBytes + newest.push(value) + } + return newest.toReversed() +} + +export function createDefaultStatsFile(): StatsFile { + return { + schemaVersion: STATS_SCHEMA_VERSION, + events: [], + aggregates: { + totalAgentsSpawned: 0, + totalPRsCreated: 0, + totalAgentTimeMs: 0, + countedPRs: [], + firstEventAt: null + } + } +} + +export function normalizeLoadedStatsFile(parsed: unknown): StatsFile { + const candidate = + typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Partial) + : {} + const aggregateCandidate: Partial = + typeof candidate.aggregates === 'object' && + candidate.aggregates !== null && + !Array.isArray(candidate.aggregates) + ? candidate.aggregates + : {} + const countedPRs = Array.isArray(aggregateCandidate.countedPRs) + ? normalizeCountedPrUrls(aggregateCandidate.countedPRs) + : [] + return { + schemaVersion: + typeof candidate.schemaVersion === 'number' ? candidate.schemaVersion : STATS_SCHEMA_VERSION, + events: Array.isArray(candidate.events) ? normalizeLoadedEvents(candidate.events) : [], + aggregates: { + totalAgentsSpawned: + typeof aggregateCandidate.totalAgentsSpawned === 'number' + ? aggregateCandidate.totalAgentsSpawned + : 0, + totalPRsCreated: + typeof aggregateCandidate.totalPRsCreated === 'number' + ? aggregateCandidate.totalPRsCreated + : 0, + totalAgentTimeMs: + typeof aggregateCandidate.totalAgentTimeMs === 'number' + ? aggregateCandidate.totalAgentTimeMs + : 0, + countedPRs, + firstEventAt: + typeof aggregateCandidate.firstEventAt === 'number' ? aggregateCandidate.firstEventAt : null + } + } +} + +export function parseLoadedStatsFile( + serialized: string, + structureLimits: JsonTextStructureLimits = STATS_FILE_JSON_LIMITS +): StatsFile { + assertJsonTextStructureWithinLimits(serialized, structureLimits) + return normalizeLoadedStatsFile(JSON.parse(serialized)) +} + +export class StatsEventLog { + private eventByteLengths: number[] + private retainedEventBytes: number + + constructor(readonly events: StatsEvent[]) { + this.eventByteLengths = events.map( + (event) => jsonByteLengthWithinLimit(event, STATS_EVENT_MAX_BYTES) ?? 0 + ) + this.retainedEventBytes = this.eventByteLengths.reduce((total, bytes) => total + bytes, 0) + } + + retain(event: StatsEvent): void { + const eventBytes = jsonByteLengthWithinLimit(event, STATS_EVENT_MAX_BYTES) + if (eventBytes === null) { + return + } + this.events.push(event) + this.eventByteLengths.push(eventBytes) + this.retainedEventBytes += eventBytes + while ( + this.events.length > STATS_EVENT_MAX_ENTRIES || + this.retainedEventBytes > STATS_EVENT_MAX_RETAINED_BYTES + ) { + this.events.shift() + this.retainedEventBytes -= this.eventByteLengths.shift() ?? 0 + } + } +} diff --git a/src/main/terminal-history-gc.test.ts b/src/main/terminal-history-gc.test.ts new file mode 100644 index 00000000000..15cfb3e972d --- /dev/null +++ b/src/main/terminal-history-gc.test.ts @@ -0,0 +1,213 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + deleteWslWorktreeHistoryDirectories, + runTerminalHistoryGarbageCollection, + TERMINAL_HISTORY_GC_META_MAX_BYTES +} from './terminal-history-gc' + +let root = '' +let mainRoot = '' +let wslRoot = '' + +function oldMetadata(worktreeId: string): string { + return JSON.stringify({ + worktreeId, + createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString() + }) +} + +async function createHistoryDirectory( + historyRoot: string, + directoryName: string, + worktreeId = directoryName +): Promise { + const directory = join(historyRoot, directoryName) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'meta.json'), oldMetadata(worktreeId)) + return directory +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-terminal-history-gc-')) + mainRoot = join(root, 'terminal-history') + wslRoot = join(root, 'terminal-history-wsl') + await mkdir(mainRoot) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('terminal history GC memory limits', () => { + it('preserves ordinary live/orphan and age-guard behavior below the limits', async () => { + const liveDirectory = await createHistoryDirectory(mainRoot, 'live-dir', 'live-worktree') + const orphanDirectory = await createHistoryDirectory(mainRoot, 'orphan-dir', 'orphan-worktree') + const freshDirectory = await createHistoryDirectory(mainRoot, 'fresh-dir', 'fresh-worktree') + await writeFile( + join(freshDirectory, 'meta.json'), + JSON.stringify({ worktreeId: 'fresh-worktree', createdAt: new Date().toISOString() }) + ) + + const summary = runTerminalHistoryGarbageCollection({ + mainRoot, + wslRoot, + liveWorktreeIds: new Set(['live-worktree']) + }) + + expect(summary).toMatchObject({ + capacityExceeded: false, + orphaned: 1, + pruned: 1, + totalDirs: 3 + }) + expect(existsSync(liveDirectory)).toBe(true) + expect(existsSync(freshDirectory)).toBe(true) + expect(existsSync(orphanDirectory)).toBe(false) + }) + + it('accepts metadata at the exact byte limit and skips one byte over', async () => { + const exactDirectory = await createHistoryDirectory(mainRoot, 'exact-dir', 'exact-worktree') + const exactMetaPath = join(exactDirectory, 'meta.json') + const exactJson = oldMetadata('exact-worktree') + await writeFile( + exactMetaPath, + `${exactJson}${' '.repeat(TERMINAL_HISTORY_GC_META_MAX_BYTES - exactJson.length)}` + ) + + expect( + runTerminalHistoryGarbageCollection({ + mainRoot, + wslRoot, + liveWorktreeIds: new Set() + }).pruned + ).toBe(1) + + const oversizedDirectory = await createHistoryDirectory( + mainRoot, + 'oversized-dir', + 'oversized-worktree' + ) + const oversizedMetaPath = join(oversizedDirectory, 'meta.json') + await writeFile(oversizedMetaPath, oldMetadata('oversized-worktree')) + await truncate(oversizedMetaPath, TERMINAL_HISTORY_GC_META_MAX_BYTES + 8 * 1024 * 1024) + + expect( + runTerminalHistoryGarbageCollection({ + mainRoot, + wslRoot, + liveWorktreeIds: new Set() + }).pruned + ).toBe(0) + expect(existsSync(oversizedDirectory)).toBe(true) + }) + + it('accepts the exact discovery-entry budget and stops before the next entry', async () => { + await createHistoryDirectory(mainRoot, 'one') + await createHistoryDirectory(mainRoot, 'two') + + const exact = runTerminalHistoryGarbageCollection({ + mainRoot, + wslRoot, + liveWorktreeIds: new Set(), + limits: { maxDiscoveryEntries: 4 } + }) + expect(exact).toMatchObject({ capacityExceeded: false, pruned: 2 }) + + await createHistoryDirectory(mainRoot, 'one') + await createHistoryDirectory(mainRoot, 'two') + await createHistoryDirectory(mainRoot, 'three') + const capped = runTerminalHistoryGarbageCollection({ + mainRoot, + wslRoot, + liveWorktreeIds: new Set(), + limits: { maxDiscoveryEntries: 4 } + }) + expect(capped.capacityExceeded).toBe(true) + expect( + [join(mainRoot, 'one'), join(mainRoot, 'two'), join(mainRoot, 'three')].some(existsSync) + ).toBe(true) + }) + + it('fails closed when a worktree directory exceeds its flat-file limit', async () => { + const directory = await createHistoryDirectory(mainRoot, 'many-files') + for (let index = 0; index < 100; index += 1) { + await writeFile(join(directory, `history-${index}`), 'x') + } + + const summary = runTerminalHistoryGarbageCollection({ + mainRoot, + wslRoot, + liveWorktreeIds: new Set(), + limits: { maxFilesPerWorktree: 1 } + }) + + expect(summary).toMatchObject({ capacityExceeded: false, pruned: 0 }) + expect(existsSync(directory)).toBe(true) + }) + + it('does not recursively prune an unexpected nested history tree', async () => { + const directory = await createHistoryDirectory(mainRoot, 'nested-tree') + await mkdir(join(directory, 'unexpected', 'deep'), { recursive: true }) + + const summary = runTerminalHistoryGarbageCollection({ + mainRoot, + wslRoot, + liveWorktreeIds: new Set() + }) + + expect(summary).toMatchObject({ capacityExceeded: false, pruned: 0 }) + expect(existsSync(directory)).toBe(true) + }) + + it('caps WSL distro roots for both GC and direct worktree cleanup', async () => { + const first = join(wslRoot, 'Distro-A') + const second = join(wslRoot, 'Distro-B') + let firstHistory = await createHistoryDirectory(first, 'hash', 'first-worktree') + let secondHistory = await createHistoryDirectory(second, 'hash', 'second-worktree') + + const exactSummary = runTerminalHistoryGarbageCollection({ + mainRoot, + wslRoot, + liveWorktreeIds: new Set(), + limits: { maxWslDistros: 2 } + }) + expect(exactSummary).toMatchObject({ capacityExceeded: false, pruned: 2 }) + + firstHistory = await createHistoryDirectory(first, 'hash', 'first-worktree') + secondHistory = await createHistoryDirectory(second, 'hash', 'second-worktree') + const cappedSummary = runTerminalHistoryGarbageCollection({ + mainRoot, + wslRoot, + liveWorktreeIds: new Set(), + limits: { maxWslDistros: 1 } + }) + expect(cappedSummary.capacityExceeded).toBe(true) + expect([firstHistory, secondHistory].some(existsSync)).toBe(true) + + await mkdir(firstHistory, { recursive: true }) + await mkdir(secondHistory, { recursive: true }) + deleteWslWorktreeHistoryDirectories({ + wslRoot, + worktreeHash: 'hash', + limits: { maxWslDistros: 2 } + }) + expect(existsSync(firstHistory)).toBe(false) + expect(existsSync(secondHistory)).toBe(false) + + await mkdir(firstHistory, { recursive: true }) + await mkdir(secondHistory, { recursive: true }) + deleteWslWorktreeHistoryDirectories({ + wslRoot, + worktreeHash: 'hash', + limits: { maxWslDistros: 1 } + }) + expect([firstHistory, secondHistory].some(existsSync)).toBe(true) + }) +}) diff --git a/src/main/terminal-history-gc.ts b/src/main/terminal-history-gc.ts new file mode 100644 index 00000000000..720db019382 --- /dev/null +++ b/src/main/terminal-history-gc.ts @@ -0,0 +1,305 @@ +import { existsSync, opendirSync, rmSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { readNodeFileSyncWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' + +export const TERMINAL_HISTORY_GC_MAX_DISCOVERY_ENTRIES = 100_000 +export const TERMINAL_HISTORY_GC_MAX_WSL_DISTROS = 256 +export const TERMINAL_HISTORY_GC_MAX_FILES_PER_WORKTREE = 64 +export const TERMINAL_HISTORY_GC_META_MAX_BYTES = 64 * 1024 + +export type TerminalHistoryGcLimits = { + maxDiscoveryEntries: number + maxFilesPerWorktree: number + maxMetaBytes: number + maxWslDistros: number +} + +export type TerminalHistoryGcSummary = { + capacityExceeded: boolean + orphaned: number + pruned: number + totalDirs: number + totalSizeKB: number +} + +const DEFAULT_LIMITS: TerminalHistoryGcLimits = { + maxDiscoveryEntries: TERMINAL_HISTORY_GC_MAX_DISCOVERY_ENTRIES, + maxFilesPerWorktree: TERMINAL_HISTORY_GC_MAX_FILES_PER_WORKTREE, + maxMetaBytes: TERMINAL_HISTORY_GC_META_MAX_BYTES, + maxWslDistros: TERMINAL_HISTORY_GC_MAX_WSL_DISTROS +} + +// Why: the live-worktree snapshot can predate a newly created history directory. +const GC_MIN_AGE_MS = 5 * 60 * 1000 + +class TerminalHistoryGcCapacityError extends Error { + constructor( + readonly resource: 'discovery entries' | 'WSL distros', + readonly limit: number + ) { + super(`Terminal history GC exceeded ${limit} ${resource}`) + this.name = 'TerminalHistoryGcCapacityError' + } +} + +class TerminalHistoryGcBudget { + private discoveryEntries = 0 + private wslDistros = 0 + + constructor(readonly limits: TerminalHistoryGcLimits) {} + + claimDiscoveryEntry(): void { + this.discoveryEntries += 1 + if (this.discoveryEntries > this.limits.maxDiscoveryEntries) { + throw new TerminalHistoryGcCapacityError('discovery entries', this.limits.maxDiscoveryEntries) + } + } + + claimWslDistro(): void { + this.wslDistros += 1 + if (this.wslDistros > this.limits.maxWslDistros) { + throw new TerminalHistoryGcCapacityError('WSL distros', this.limits.maxWslDistros) + } + } +} + +type MutableTerminalHistoryGcSummary = Omit + +export function runTerminalHistoryGarbageCollection(options: { + mainRoot: string + wslRoot: string + liveWorktreeIds: Set + limits?: Partial +}): TerminalHistoryGcSummary { + const limits = resolveTerminalHistoryGcLimits(options.limits) + const budget = new TerminalHistoryGcBudget(limits) + const summary: TerminalHistoryGcSummary = { + capacityExceeded: false, + orphaned: 0, + pruned: 0, + totalDirs: 0, + totalSizeKB: 0 + } + + try { + scanTerminalHistoryRoot(options.mainRoot, options.liveWorktreeIds, budget, limits, summary) + scanWslHistoryRoots(options.wslRoot, options.liveWorktreeIds, budget, limits, summary) + } catch (error) { + if (!(error instanceof TerminalHistoryGcCapacityError)) { + throw error + } + summary.capacityExceeded = true + console.warn(`[pty:history:gc] ${error.message}; remaining history will be scanned next run`) + } + return summary +} + +export function deleteWslWorktreeHistoryDirectories(options: { + wslRoot: string + worktreeHash: string + limits?: Partial +}): void { + if (!existsSync(options.wslRoot)) { + return + } + const limits = resolveTerminalHistoryGcLimits(options.limits) + const budget = new TerminalHistoryGcBudget(limits) + try { + forEachDirectoryEntry(options.wslRoot, (distro) => { + budget.claimDiscoveryEntry() + const distroRoot = join(options.wslRoot, distro) + if (!statSync(distroRoot).isDirectory()) { + return + } + budget.claimWslDistro() + const historyPath = join(distroRoot, options.worktreeHash) + if (existsSync(historyPath)) { + rmSync(historyPath, { recursive: true, force: true }) + } + }) + } catch (error) { + if (error instanceof TerminalHistoryGcCapacityError) { + console.warn(`[pty:history] ${error.message}; WSL cleanup stopped at the limit`) + return + } + throw error + } +} + +function scanWslHistoryRoots( + wslRoot: string, + liveWorktreeIds: Set, + budget: TerminalHistoryGcBudget, + limits: TerminalHistoryGcLimits, + summary: TerminalHistoryGcSummary +): void { + if (!existsSync(wslRoot)) { + return + } + try { + forEachDirectoryEntry(wslRoot, (distro) => { + budget.claimDiscoveryEntry() + const distroRoot = join(wslRoot, distro) + try { + if (!statSync(distroRoot).isDirectory()) { + return + } + budget.claimWslDistro() + scanTerminalHistoryRoot(distroRoot, liveWorktreeIds, budget, limits, summary) + } catch (error) { + if (error instanceof TerminalHistoryGcCapacityError) { + throw error + } + // One unavailable distro must not discard the main-root GC result. + } + }) + } catch (error) { + if (error instanceof TerminalHistoryGcCapacityError) { + throw error + } + // WSL history is optional and may disappear while distributions stop. + } +} + +function scanTerminalHistoryRoot( + root: string, + liveWorktreeIds: Set, + budget: TerminalHistoryGcBudget, + limits: TerminalHistoryGcLimits, + summary: MutableTerminalHistoryGcSummary +): void { + if (!existsSync(root)) { + return + } + const now = Date.now() + + forEachDirectoryEntry(root, (entry) => { + budget.claimDiscoveryEntry() + const entryPath = join(root, entry) + try { + if (!statSync(entryPath).isDirectory()) { + return + } + summary.totalDirs += 1 + const sizeEstimate = estimateHistoryDirectorySize(entryPath, budget, limits) + summary.totalSizeKB += sizeEstimate.totalSizeKB + if (!sizeEstimate.complete) { + return + } + + const meta = readTerminalHistoryMetadata(join(entryPath, 'meta.json'), limits.maxMetaBytes) + if (!meta || liveWorktreeIds.has(meta.worktreeId)) { + return + } + if (meta.createdAt) { + const ageMs = now - new Date(meta.createdAt).getTime() + if (ageMs < GC_MIN_AGE_MS) { + return + } + } + + summary.orphaned += 1 + rmSync(entryPath, { recursive: true, force: true }) + summary.pruned += 1 + console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`) + } catch (error) { + if (error instanceof TerminalHistoryGcCapacityError) { + throw error + } + // One corrupt or concurrently removed history directory must not stop GC. + } + }) +} + +function estimateHistoryDirectorySize( + directoryPath: string, + budget: TerminalHistoryGcBudget, + limits: TerminalHistoryGcLimits +): { complete: boolean; totalSizeKB: number } { + let complete = true + let fileCount = 0 + let totalSizeKB = 0 + try { + forEachDirectoryEntry(directoryPath, (file) => { + budget.claimDiscoveryEntry() + fileCount += 1 + if (fileCount > limits.maxFilesPerWorktree) { + complete = false + return false + } + const fileStat = statSync(join(directoryPath, file)) + if (fileStat.isDirectory()) { + complete = false + return false + } + totalSizeKB += Math.ceil(fileStat.size / 1024) + return undefined + }) + } catch (error) { + if (error instanceof TerminalHistoryGcCapacityError) { + throw error + } + complete = false + } + return { complete, totalSizeKB } +} + +function readTerminalHistoryMetadata( + metaPath: string, + maxBytes: number +): { worktreeId: string; createdAt?: string } | null { + if (!existsSync(metaPath)) { + return null + } + try { + const parsed = JSON.parse( + readNodeFileSyncWithinLimit(metaPath, maxBytes).buffer.toString('utf8') + ) as unknown + if ( + parsed === null || + typeof parsed !== 'object' || + !('worktreeId' in parsed) || + typeof parsed.worktreeId !== 'string' || + parsed.worktreeId.length === 0 + ) { + return null + } + const createdAt = + 'createdAt' in parsed && typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined + return { worktreeId: parsed.worktreeId, ...(createdAt ? { createdAt } : {}) } + } catch { + return null + } +} + +function forEachDirectoryEntry( + directoryPath: string, + visit: (entryName: string) => false | void +): void { + const directory = opendirSync(directoryPath) + try { + for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) { + if (visit(entry.name) === false) { + return + } + } + } finally { + try { + directory.closeSync() + } catch { + // The OS may have already closed a failed directory stream. + } + } +} + +function resolveTerminalHistoryGcLimits( + overrides: Partial = {} +): TerminalHistoryGcLimits { + const limits = { ...DEFAULT_LIMITS, ...overrides } + for (const [name, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + } + return limits +} diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index a9750bb60bc..af71d46a2f4 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -7,6 +7,7 @@ const { readFileSyncMock, rmSyncMock, readdirSyncMock, + opendirSyncMock, statSyncMock, getPathMock } = vi.hoisted(() => ({ @@ -16,6 +17,7 @@ const { readFileSyncMock: vi.fn(), rmSyncMock: vi.fn(), readdirSyncMock: vi.fn(), + opendirSyncMock: vi.fn(), statSyncMock: vi.fn(), getPathMock: vi.fn() })) @@ -27,9 +29,21 @@ vi.mock('fs', () => ({ readFileSync: readFileSyncMock, rmSync: rmSyncMock, readdirSync: readdirSyncMock, + opendirSync: opendirSyncMock, statSync: statSyncMock })) +vi.mock('../shared/memory-safety/node-bounded-file-reader', () => ({ + readNodeFileSyncWithinLimit: (filePath: string, maxBytes: number) => { + const value = readFileSyncMock(filePath, 'utf8') + const buffer = Buffer.isBuffer(value) ? value : Buffer.from(String(value)) + if (buffer.byteLength > maxBytes) { + throw new Error('File too large') + } + return { buffer, stats: statSyncMock(filePath) } + } +})) + vi.mock('electron', () => ({ app: { getPath: getPathMock @@ -66,7 +80,22 @@ describe('terminal-history', () => { vi.clearAllMocks() getPathMock.mockReturnValue('/fake/userData') existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) + statSyncMock.mockImplementation((filePath: string) => ({ + isDirectory: () => !filePath.endsWith('meta.json'), + size: 100 + })) + opendirSyncMock.mockImplementation((directoryPath: string) => { + const entries = (readdirSyncMock(directoryPath) ?? []) as string[] + let index = 0 + return { + closeSync: vi.fn(), + readSync: () => { + const name = entries[index] + index += 1 + return name === undefined ? null : { name } + } + } + }) }) describe('resolveShellKind', () => { @@ -302,7 +331,10 @@ describe('terminal-history', () => { } return ['meta.json'] }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) + statSyncMock.mockImplementation((filePath: string) => ({ + isDirectory: () => !filePath.endsWith('meta.json'), + size: 100 + })) readFileSyncMock.mockImplementation((p: string) => { // Use a createdAt old enough to pass the GC age threshold const oldDate = new Date(Date.now() - 10 * 60 * 1000).toISOString() @@ -336,7 +368,10 @@ describe('terminal-history', () => { } return ['meta.json'] }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) + statSyncMock.mockImplementation((filePath: string) => ({ + isDirectory: () => !filePath.endsWith('meta.json'), + size: 100 + })) // createdAt is just now — younger than the 5-minute GC threshold readFileSyncMock.mockReturnValue( JSON.stringify({ worktreeId: 'unknown-wt', createdAt: new Date().toISOString() }) diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts index a6d09cfcaa2..833e82347cb 100644 --- a/src/main/terminal-history.ts +++ b/src/main/terminal-history.ts @@ -1,16 +1,12 @@ import { createHash } from 'node:crypto' import { join, basename } from 'node:path' -import { - mkdirSync, - existsSync, - readFileSync, - writeFileSync, - readdirSync, - rmSync, - statSync -} from 'node:fs' +import { mkdirSync, existsSync, writeFileSync, rmSync } from 'node:fs' import { app } from 'electron' import { parseWslPath, toLinuxPath } from './wsl' +import { + deleteWslWorktreeHistoryDirectories, + runTerminalHistoryGarbageCollection +} from './terminal-history-gc' // ─── Constants ───────────────────────────────────────────────────── @@ -227,14 +223,7 @@ export function deleteWorktreeHistoryDir(worktreeId: string): void { if (process.platform === 'win32') { try { const wslRoot = join(app.getPath('userData'), HISTORY_DIR_NAME_WSL) - if (existsSync(wslRoot)) { - for (const distro of readdirSync(wslRoot)) { - const wslDir = join(wslRoot, distro, worktreeHash) - if (existsSync(wslDir)) { - rmSync(wslDir, { recursive: true, force: true }) - } - } - } + deleteWslWorktreeHistoryDirectories({ wslRoot, worktreeHash }) } catch { // Non-fatal. } @@ -243,112 +232,19 @@ export function deleteWorktreeHistoryDir(worktreeId: string): void { // ─── Garbage Collection ──────────────────────────────────────────── -// Why 5 minutes: GC runs ~10s after startup, and the live-worktree snapshot is -// taken just before. A worktree created between the snapshot and GC execution -// won't appear in liveWorktreeIds, so without an age guard GC would delete its -// freshly-created history directory (TOCTOU race). 5 minutes is generous enough -// to cover any realistic snapshot-to-scan delay. -const GC_MIN_AGE_MS = 5 * 60 * 1000 - -/** Scan a single history root directory, pruning orphaned entries. - * Returns { totalDirs, orphaned, pruned, totalSizeKB }. */ -function gcScanRoot( - root: string, - liveWorktreeIds: Set -): { totalDirs: number; orphaned: number; pruned: number; totalSizeKB: number } { - const result = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 } - if (!existsSync(root)) { - return result - } - - const now = Date.now() - - for (const entry of readdirSync(root)) { - const entryPath = join(root, entry) - try { - const stat = statSync(entryPath) - if (!stat.isDirectory()) { - continue - } - result.totalDirs++ - - // Estimate directory size from meta.json + history files. - try { - for (const file of readdirSync(entryPath)) { - result.totalSizeKB += Math.ceil(statSync(join(entryPath, file)).size / 1024) - } - } catch { - // Skip size estimation on error. - } - - const metaPath = join(entryPath, 'meta.json') - if (!existsSync(metaPath)) { - // No meta.json — can't determine ownership, skip. - continue - } - - const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as { - worktreeId?: string - createdAt?: string - } - if (!meta.worktreeId) { - continue - } - - if (!liveWorktreeIds.has(meta.worktreeId)) { - // Why: avoid a TOCTOU race where a worktree is created after the - // live-ID snapshot but before GC runs. Directories younger than - // GC_MIN_AGE_MS are presumed still live and skipped. - if (meta.createdAt) { - const ageMs = now - new Date(meta.createdAt).getTime() - if (ageMs < GC_MIN_AGE_MS) { - continue - } - } - - result.orphaned++ - rmSync(entryPath, { recursive: true, force: true }) - result.pruned++ - console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`) - } - } catch { - // Skip individual entries that fail. - } - } - return result -} - /** Run background GC to prune history directories for worktrees that are no * longer in Orca's known live-worktree set. */ export function runHistoryGc(liveWorktreeIds: Set): void { try { - const main = gcScanRoot(getHistoryRoot(), liveWorktreeIds) - - // Also scan WSL history directories (each distro has its own subdirectory). const wslRoot = join(app.getPath('userData'), HISTORY_DIR_NAME_WSL) - let wslTotals = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 } - if (existsSync(wslRoot)) { - try { - for (const distro of readdirSync(wslRoot)) { - const distroRoot = join(wslRoot, distro) - const r = gcScanRoot(distroRoot, liveWorktreeIds) - wslTotals.totalDirs += r.totalDirs - wslTotals.orphaned += r.orphaned - wslTotals.pruned += r.pruned - wslTotals.totalSizeKB += r.totalSizeKB - } - } catch { - // Non-fatal. - } - } - - const totalDirs = main.totalDirs + wslTotals.totalDirs - const orphaned = main.orphaned + wslTotals.orphaned - const pruned = main.pruned + wslTotals.pruned - const totalSizeKB = main.totalSizeKB + wslTotals.totalSizeKB + const summary = runTerminalHistoryGarbageCollection({ + mainRoot: getHistoryRoot(), + wslRoot, + liveWorktreeIds + }) console.log( - `[pty:history:gc] totalDirs=${totalDirs} orphaned=${orphaned} pruned=${pruned} totalSizeKB=${totalSizeKB}` + `[pty:history:gc] totalDirs=${summary.totalDirs} orphaned=${summary.orphaned} pruned=${summary.pruned} totalSizeKB=${summary.totalSizeKB}` ) } catch (err) { console.warn(`[pty:history:gc] GC failed: ${err instanceof Error ? err.message : String(err)}`) diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 3b921daf65b..0537d4ed10e 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -16,6 +16,7 @@ import { generateBranchNameFromContext, generateCommitMessageFromContext, generatePullRequestFieldsFromContext, + MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS, resolveCommitMessageSettings, trimGeneratedCommitMessage } from './commit-message-text-generation' @@ -479,6 +480,119 @@ describe('discoverCommitMessageModelsLocal', () => { }) }) + it('shares one child process for concurrent identical model discovery', async () => { + const children: MockDiscoveryChild[] = [] + spawnMock.mockImplementation(() => { + const child = createMockDiscoveryChild() + children.push(child) + return child as never + }) + + const first = discoverCommitMessageModelsLocal('cursor', { TOKEN: 'secret' }) + const second = discoverCommitMessageModelsLocal('cursor', { TOKEN: 'secret' }) + + expect(spawnMock).toHaveBeenCalledTimes(1) + children[0]!.stdout.emit('data', Buffer.from('auto - Auto\n')) + children[0]!.emit('close', 0) + + const [firstResult, secondResult] = await Promise.all([first, second]) + expect(firstResult).toMatchObject({ success: true, defaultModelId: 'auto' }) + expect(secondResult).toEqual(firstResult) + + const retry = discoverCommitMessageModelsLocal('cursor', { TOKEN: 'secret' }) + expect(spawnMock).toHaveBeenCalledTimes(2) + children[1]!.stdout.emit('data', Buffer.from('auto - Auto\n')) + children[1]!.emit('close', 0) + await expect(retry).resolves.toEqual(firstResult) + }) + + it('keeps different agent parsers isolated when discovery commands match', async () => { + const children: MockDiscoveryChild[] = [] + spawnMock.mockImplementation(() => { + const child = createMockDiscoveryChild() + children.push(child) + return child as never + }) + + const cursor = discoverCommitMessageModelsLocal('cursor', undefined, 'custom-discovery') + const pi = discoverCommitMessageModelsLocal('pi', undefined, 'custom-discovery') + + expect(children).toHaveLength(2) + children[0]!.stdout.emit('data', Buffer.from('auto - Auto\n')) + children[0]!.emit('close', 0) + children[1]!.stderr.emit( + 'data', + Buffer.from( + [ + 'provider model context max-out thinking images', + 'github-copilot gpt-5.4-mini 400K 128K yes yes' + ].join('\n') + ) + ) + children[1]!.emit('close', 0) + + await expect(cursor).resolves.toMatchObject({ success: true, defaultModelId: 'auto' }) + await expect(pi).resolves.toMatchObject({ + success: true, + defaultModelId: 'github-copilot/gpt-5.4-mini' + }) + }) + + it('shares the local process cap across discovery and generation, then releases on close', async () => { + const children: MockDiscoveryChild[] = [] + spawnMock.mockImplementation(() => { + const child = createMockDiscoveryChild() + child.pid += children.length + children.push(child) + return child as never + }) + const active = Array.from({ length: MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS }, (_, index) => + discoverCommitMessageModelsLocal('cursor', undefined, undefined, { + cwd: `/repo-${index}` + }) + ) + + expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) + await expect( + discoverCommitMessageModelsLocal('cursor', undefined, undefined, { + cwd: '/repo-overflow' + }) + ).resolves.toEqual({ + success: false, + error: + 'Too many local AI generations are already running. Wait for one to finish and try again.' + }) + await expect( + generateCommitMessageFromContext( + { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }, + { agentId: 'custom', model: '', customAgentCommand: 'agent' }, + { kind: 'local', cwd: '/generation-overflow' } + ) + ).resolves.toEqual({ + success: false, + error: + 'Too many local AI generations are already running. Wait for one to finish and try again.' + }) + expect(spawnMock).toHaveBeenCalledTimes(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) + + children[0]!.stdout.emit('data', Buffer.from('auto - Auto\n')) + children[0]!.emit('close', 0) + await active[0] + + const retry = discoverCommitMessageModelsLocal('cursor', undefined, undefined, { + cwd: '/repo-overflow' + }) + expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + 1) + + for (const child of children.slice(1)) { + child.stdout.emit('data', Buffer.from('auto - Auto\n')) + child.emit('close', 0) + } + await expect(Promise.all([...active, retry])).resolves.toHaveLength( + MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + 1 + ) + }) + it('settles and detaches model discovery when timeout kill is ignored', async () => { vi.useFakeTimers() const child = createMockDiscoveryChild() @@ -498,6 +612,8 @@ describe('discoverCommitMessageModelsLocal', () => { expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(1) + child.emit('close', null) expect(child.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() @@ -520,6 +636,8 @@ describe('discoverCommitMessageModelsLocal', () => { expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(1) + child.emit('close', null) expect(child.listenerCount('close')).toBe(0) }) }) @@ -1141,6 +1259,36 @@ describe('generateCommitMessageFromContext', () => { expectChildTerminated(child) }) + it('preserves local agent output delivered as 100,000 one-byte fragments', async () => { + const child = createMockDiscoveryChild() + spawnMock.mockReturnValue(child as never) + const pending = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { kind: 'local', cwd: '/fragmented-repo' } + ) + + for (let index = 0; index < 100_000; index += 1) { + child.stdout.emit('data', Buffer.from(' ')) + } + child.stdout.emit('data', Buffer.from('Update README\n')) + child.emit('close', 0) + + await expect(pending).resolves.toEqual({ + success: true, + message: 'Update README', + agentLabel: 'agent' + }) + }) + it('passes prepared provider environment to local agent subprocesses', async () => { const listeners = new Map void>() const child = { @@ -1338,6 +1486,207 @@ describe('generateCommitMessageFromContext', () => { expect(children[1]?.kill).not.toHaveBeenCalled() }) + it('cancels the previous process before replacing one local lane', async () => { + const children: { + kill: ReturnType + listeners: Map void> + }[] = [] + spawnMock.mockImplementation(() => { + const listeners = new Map void>() + const child = { + pid: 123 + children.length, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + children.push({ kill: child.kill, listeners }) + return child as never + }) + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'custom' as const, model: '', customAgentCommand: 'agent' } + const target = { kind: 'local' as const, cwd: '/same-repo' } + + const first = generateCommitMessageFromContext(context, params, target) + const second = generateCommitMessageFromContext(context, params, target) + + expectChildTerminated({ pid: 123, kill: children[0]!.kill }) + expect(children).toHaveLength(2) + children[0]?.listeners.get('close')?.(null) + children[1]?.listeners.get('stdout:data')?.(Buffer.from('Update README\n')) + children[1]?.listeners.get('close')?.(0) + await expect(first).resolves.toMatchObject({ success: false, canceled: true }) + await expect(second).resolves.toMatchObject({ success: true, message: 'Update README' }) + }) + + it('caps concurrent local text-generation child processes', async () => { + const children: Map void>[] = [] + spawnMock.mockImplementation(() => { + const listeners = new Map void>() + const child = { + pid: 200 + children.length, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + children.push(listeners) + return child as never + }) + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'custom' as const, model: '', customAgentCommand: 'agent' } + const active = Array.from({ length: MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS }, (_, index) => + generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: `/repo-${index}` + }) + ) + + await expect( + generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: '/repo-overflow' + }) + ).resolves.toEqual({ + success: false, + error: + 'Too many local AI generations are already running. Wait for one to finish and try again.' + }) + expect(spawnMock).toHaveBeenCalledTimes(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) + + for (const listeners of children) { + listeners.get('stdout:data')?.(Buffer.from('Update README\n')) + listeners.get('close')?.(0) + } + await expect(Promise.all(active)).resolves.toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) + }) + + it('waits for a full-capacity lane to close before spawning its replacement', async () => { + const children: { + pid: number + kill: ReturnType + listeners: Map void> + closed: boolean + }[] = [] + spawnMock.mockImplementation(() => { + const listeners = new Map void>() + const child = { + pid: 300 + children.length, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + children.push({ pid: child.pid, kill: child.kill, listeners, closed: false }) + return child as never + }) + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'custom' as const, model: '', customAgentCommand: 'agent' } + const active = Array.from({ length: MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS }, (_, index) => + generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: `/full-repo-${index}` + }) + ) + + const replacement = generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: '/full-repo-0' + }) + + expectChildTerminated(children[0]!) + expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) + expect(children.filter((child) => !child.closed)).toHaveLength( + MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + ) + + children[0]!.closed = true + children[0]!.listeners.get('close')?.(null) + await Promise.resolve() + await Promise.resolve() + + expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + 1) + expect(children.filter((child) => !child.closed)).toHaveLength( + MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + ) + + for (const child of children.slice(1)) { + child.listeners.get('stdout:data')?.(Buffer.from('Update README\n')) + child.closed = true + child.listeners.get('close')?.(0) + } + await expect(active[0]).resolves.toMatchObject({ success: false, canceled: true }) + await expect(Promise.all([...active.slice(1), replacement])).resolves.toHaveLength( + MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + ) + }) + + it('cancels and supersedes replacements queued behind a full lane', async () => { + const children: { + pid: number + kill: ReturnType + listeners: Map void> + }[] = [] + spawnMock.mockImplementation(() => { + const listeners = new Map void>() + const child = { + pid: 400 + children.length, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + children.push({ pid: child.pid, kill: child.kill, listeners }) + return child as never + }) + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'custom' as const, model: '', customAgentCommand: 'agent' } + const active = Array.from({ length: MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS }, (_, index) => + generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: `/queued-repo-${index}` + }) + ) + + const superseded = generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: '/queued-repo-0' + }) + const canceled = generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: '/queued-repo-0' + }) + + await expect(superseded).resolves.toMatchObject({ success: false, canceled: true }) + expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) + cancelGenerateCommitMessageLocal('/queued-repo-0') + await expect(canceled).resolves.toMatchObject({ success: false, canceled: true }) + + const replacement = generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: '/queued-repo-0' + }) + expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) + + children[0]!.listeners.get('close')?.(null) + await Promise.resolve() + await Promise.resolve() + expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + 1) + + for (const child of children.slice(1)) { + child.listeners.get('stdout:data')?.(Buffer.from('Update README\n')) + child.listeners.get('close')?.(0) + } + await expect(active[0]).resolves.toMatchObject({ success: false, canceled: true }) + await expect(Promise.all([...active.slice(1), replacement])).resolves.toHaveLength( + MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + ) + }) + it('keeps local pull-request cancellation from stopping commit-message generation', async () => { const children: { pid: number @@ -1612,7 +1961,8 @@ describe('generateCommitMessageFromContext', () => { expect(listeners.has('stdout:data')).toBe(false) expect(listeners.has('stderr:data')).toBe(false) expect(listeners.has('error')).toBe(false) - expect(listeners.has('close')).toBe(false) + expect(listeners.has('close')).toBe(true) + listeners.get('close')?.(null) } finally { vi.useRealTimers() } @@ -1888,6 +2238,168 @@ describe('generateBranchNameFromContext', () => { }) }) +describe('linkedIssue template substitution', () => { + const COMMIT_CONTEXT = { + branch: 'feature/login', + stagedSummary: 'M src/login.ts', + stagedPatch: 'diff --git a/src/login.ts b/src/login.ts' + } + const PULL_REQUEST_CONTEXT = { + branch: 'feature/login', + base: 'main', + branchChangedByPreparation: false, + currentTitle: 'Fix login', + currentBody: '', + currentDraft: false, + commitSummary: 'a1b2c3d Fix login', + changeSummary: 'src/login.ts | 4 ++--', + patch: 'diff --git a/src/login.ts b/src/login.ts' + } + + function capturingTarget(capture: (prompt: string) => void): { + kind: 'remote' + cwd: string + missingBinaryLocation: string + execute: (plan: { stdinPayload: string | null }) => Promise<{ + stdout: string + stderr: string + exitCode: number + timedOut: boolean + }> + } { + return { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async (plan) => { + capture(plan.stdinPayload ?? '') + return { + stdout: '{"base":"main","title":"Fix login","body":"body","draft":false}', + stderr: '', + exitCode: 0, + timedOut: false + } + } + } + } + + const templateParams = { + agentId: 'custom' as const, + model: '', + customAgentCommand: 'agent', + commandInputTemplate: '{basePrompt}\n\nFixes #{linkedIssue}' + } + + it('substitutes the linked issue into the commit-message prompt', async () => { + let prompt = '' + await generateCommitMessageFromContext( + { ...COMMIT_CONTEXT, linkedIssue: 42 }, + templateParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).toContain('Fixes #42') + expect(prompt).not.toContain('{linkedIssue}') + }) + + it('renders an empty commit-message issue for null and omitted fields', async () => { + for (const context of [{ ...COMMIT_CONTEXT, linkedIssue: null }, COMMIT_CONTEXT]) { + let prompt = '' + await generateCommitMessageFromContext( + context, + templateParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).toContain('Fixes #') + expect(prompt).not.toContain('{linkedIssue}') + } + }) + + // Why: a fixture-unique sentinel — a short number like 42 also appears in the + // character counts that truncateDiffForPrompt/limitSection emit, so growing any + // fixture past its limit would fail these guards for reasons unrelated to leakage. + const BUILT_IN_PROMPT_SENTINEL_ISSUE = 987654 + const builtInPromptParams = { + agentId: 'custom' as const, + model: '', + customAgentCommand: 'agent' + } + + it('leaves the built-in commit prompt free of issue guidance', async () => { + let prompt = '' + await generateCommitMessageFromContext( + { ...COMMIT_CONTEXT, linkedIssue: BUILT_IN_PROMPT_SENTINEL_ISSUE }, + builtInPromptParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).not.toContain(String(BUILT_IN_PROMPT_SENTINEL_ISSUE)) + expect(prompt).not.toContain('linkedIssue') + }) + + it('leaves the built-in pull-request prompt free of issue guidance', async () => { + let prompt = '' + await generatePullRequestFieldsFromContext( + { ...PULL_REQUEST_CONTEXT, linkedIssue: BUILT_IN_PROMPT_SENTINEL_ISSUE }, + builtInPromptParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).not.toContain(String(BUILT_IN_PROMPT_SENTINEL_ISSUE)) + expect(prompt).not.toContain('linkedIssue') + }) + + it('substitutes the linked issue into the pull-request prompt', async () => { + let prompt = '' + await generatePullRequestFieldsFromContext( + { ...PULL_REQUEST_CONTEXT, linkedIssue: 7 }, + templateParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).toContain('Fixes #7') + expect(prompt).not.toContain('{linkedIssue}') + }) + + it('renders an empty pull-request issue when none resolves', async () => { + let prompt = '' + await generatePullRequestFieldsFromContext( + PULL_REQUEST_CONTEXT, + templateParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).toContain('Fixes #') + expect(prompt).not.toContain('{linkedIssue}') + }) + + it('leaves a hand-typed linkedIssue literal in branch-name templates', async () => { + let prompt = '' + await generateBranchNameFromContext( + { firstPrompt: 'Fix login flow' }, + { ...templateParams, commandInputTemplate: '{basePrompt}\n\nIssue {linkedIssue}' }, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).toContain('Issue {linkedIssue}') + }) +}) + describe('trimGeneratedCommitMessage', () => { it('removes trailing whitespace from generated messages', () => { const message = trimGeneratedCommitMessage('Update docs\n\n') diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index ff0c9e5fc24..db103c53780 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -2,6 +2,8 @@ spawn failure handling, and output normalization; keeping them together prevents those paths from drifting. */ import { exec, spawn, type ChildProcess } from 'node:child_process' +import { createHash } from 'node:crypto' +import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import type { GlobalSettings, Repo, TuiAgent } from '../../shared/types' import { buildCommitMessagePrompt, @@ -44,6 +46,7 @@ import { type ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' import type { SourceControlAiOperation } from '../../shared/source-control-ai-types' +import { formatLinkedIssueTemplateValue } from '../../shared/source-control-ai-action-variables' import { renderSourceControlActionCommandTemplate } from '../../shared/source-control-ai-actions' import { resolveCliCommand } from '../codex-cli/command' import { @@ -53,6 +56,10 @@ import { } from '../win32-utils' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { wslAwareSpawn } from '../git/runner' +import { + MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS, + reserveLocalAiProcess +} from './local-ai-process-budget' const GENERATION_TIMEOUT_MS = 60_000 const MAX_AGENT_OUTPUT_BYTES = 4 * 1024 * 1024 @@ -296,6 +303,29 @@ function planModelDiscovery( } } +const inFlightLocalModelDiscoveries = new Map>() + +function localModelDiscoveryKey( + agentId: TuiAgent, + plan: CommitMessagePlan, + env: NodeJS.ProcessEnv, + options: CommitMessageModelDiscoveryLocalOptions +): string { + // Hash the full identity so environment and command secrets never remain in map keys. + return createHash('sha256') + .update( + JSON.stringify([ + agentId, + plan.binary, + plan.args, + options.cwd ?? null, + options.wslDistro ?? null, + Object.entries(env).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + ]) + ) + .digest('hex') +} + export async function discoverCommitMessageModelsLocal( agentId: TuiAgent, env: NodeJS.ProcessEnv | undefined, @@ -311,19 +341,49 @@ export async function discoverCommitMessageModelsLocal( return toModelDiscoveryCapability(spec) } + const planned = planModelDiscovery(spec, agentCommandOverride) + if (!planned.ok) { + return { success: false, error: planned.error } + } + const spawnEnv = env ?? process.env + const discoveryKey = localModelDiscoveryKey(spec.id, planned.plan, spawnEnv, options) + const inFlight = inFlightLocalModelDiscoveries.get(discoveryKey) + if (inFlight) { + return inFlight + } + const pending = runLocalModelDiscovery(spec, planned.plan, env, spawnEnv, options) + inFlightLocalModelDiscoveries.set(discoveryKey, pending) + const clearPending = (): void => { + if (inFlightLocalModelDiscoveries.get(discoveryKey) === pending) { + inFlightLocalModelDiscoveries.delete(discoveryKey) + } + } + void pending.then(clearPending, clearPending) + return pending +} + +function runLocalModelDiscovery( + spec: NonNullable>, + plan: CommitMessagePlan, + explicitEnv: NodeJS.ProcessEnv | undefined, + spawnEnv: NodeJS.ProcessEnv, + options: CommitMessageModelDiscoveryLocalOptions +): Promise { + const reservation = reserveLocalAiProcess() + if (!reservation) { + return Promise.resolve({ + success: false, + error: + 'Too many local AI generations are already running. Wait for one to finish and try again.' + }) + } return new Promise((resolve) => { let child: ChildProcess - const spawnEnv = env ?? process.env try { - const planned = planModelDiscovery(spec, agentCommandOverride) - if (!planned.ok) { - resolve({ success: false, error: planned.error }) - return - } if (process.platform === 'win32' && options.wslDistro) { - child = wslAwareSpawn(planned.plan.binary, planned.plan.args, { + child = wslAwareSpawn(plan.binary, plan.args, { cwd: options.cwd, - env: buildWslLauncherEnv(env), + env: buildWslLauncherEnv(explicitEnv), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, wslDistro: options.wslDistro, @@ -332,11 +392,11 @@ export async function discoverCommitMessageModelsLocal( } else { const resolvedBinary = process.platform === 'win32' - ? resolveCliCommand(planned.plan.binary, { + ? resolveCliCommand(plan.binary, { pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null }) - : planned.plan.binary - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args) + : plan.binary + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, plan.args) child = spawn(spawnCmd, spawnArgs, { env: spawnEnv, stdio: ['ignore', 'pipe', 'pipe'], @@ -344,6 +404,7 @@ export async function discoverCommitMessageModelsLocal( }) } } catch (error) { + reservation.release() console.error('[commit-message] Failed to spawn model discovery:', error) resolve({ success: false, @@ -351,9 +412,10 @@ export async function discoverCommitMessageModelsLocal( }) return } + const owner = reservation.register(child) - let stdout = '' - let stderr = '' + const stdout = new GrowingByteBuffer() + const stderr = new GrowingByteBuffer() let outputLimitExceeded = false let settled = false let timer: ReturnType | null = null @@ -368,6 +430,8 @@ export async function discoverCommitMessageModelsLocal( timer = null } detachChildListeners() + stdout.clear() + stderr.clear() resolve(result) } timer = setTimeout(() => { @@ -378,18 +442,18 @@ export async function discoverCommitMessageModelsLocal( }) }, GENERATION_TIMEOUT_MS) - const onData = (chunk: Buffer, append: (text: string) => void): void => { - if (stdout.length + stderr.length + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) { + const onData = (chunk: Buffer, append: (value: Buffer) => void): void => { + if (stdout.byteLength + stderr.byteLength + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) { outputLimitExceeded = true killProcessTree(child) finish({ success: false, error: `${spec.label} returned too much model data.` }) return } - append(chunk.toString('utf-8')) + append(chunk) } - const onStdoutData = (chunk: Buffer): void => onData(chunk, (text) => (stdout += text)) - const onStderrData = (chunk: Buffer): void => onData(chunk, (text) => (stderr += text)) + const onStdoutData = (chunk: Buffer): void => onData(chunk, (value) => stdout.append(value)) + const onStderrData = (chunk: Buffer): void => onData(chunk, (value) => stderr.append(value)) const onError = (error: Error): void => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { finish({ @@ -404,15 +468,17 @@ export async function discoverCommitMessageModelsLocal( }) } const onClose = (code: number | null): void => { + child.off?.('close', onClose) + owner.release() if (outputLimitExceeded) { finish({ success: false, error: `${spec.label} returned too much model data.` }) return } if (code !== 0) { - finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code)) + finish(finalizeModelDiscoveryOutput(spec, stdout.toString(), stderr.toString(), code)) return } - finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code)) + finish(finalizeModelDiscoveryOutput(spec, stdout.toString(), stderr.toString(), code)) } child.stdout?.on('data', onStdoutData) @@ -423,7 +489,6 @@ export async function discoverCommitMessageModelsLocal( child.stdout?.off?.('data', onStdoutData) child.stderr?.off?.('data', onStderrData) child.off?.('error', onError) - child.off?.('close', onClose) } }) } @@ -514,6 +579,8 @@ function killProcessTree(child: ChildProcess): void { // Keying by operation plus `local:${cwd}` keeps local cancellation independent // from SSH worktrees and from other generation features in the same worktree. const cancelTokensByLane = new Map void>() +const pendingReservationCancelTokensByLane = new Map void>() +export { MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS } const WSL_LAUNCHER_ENV_KEYS = [ 'ComSpec', 'COMSPEC', @@ -530,8 +597,17 @@ function localLaneKey(operation: TextGenerationOperation, cwd: string): string { return `${operation}:local:${cwd}` } +function cancelLocalGenerationLane(laneKey: string): void { + const cancelPendingReservation = pendingReservationCancelTokensByLane.get(laneKey) + if (cancelPendingReservation) { + cancelPendingReservation() + return + } + cancelTokensByLane.get(laneKey)?.() +} + export function cancelGenerateCommitMessageLocal(cwd: string): void { - cancelTokensByLane.get(localLaneKey('commit-message', cwd))?.() + cancelLocalGenerationLane(localLaneKey('commit-message', cwd)) } function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { @@ -559,6 +635,46 @@ async function runLocalPlan( wslDistro?: string ): Promise { const { binary, args, stdinPayload, label } = plan + const laneKey = localLaneKey(operation, cwd) + pendingReservationCancelTokensByLane.get(laneKey)?.() + const reservation = reserveLocalAiProcess(laneKey) + if (!reservation) { + return { + success: false, + error: + 'Too many local AI generations are already running. Wait for one to finish and try again.' + } + } + const previousCancelToken = cancelTokensByLane.get(laneKey) + if (reservation.waitForClose) { + let canceledWhileWaiting = false + let resolvePendingCancellation = (): void => {} + const pendingCancellation = new Promise((resolve) => { + resolvePendingCancellation = resolve + }) + const cancelPendingReservation = (): void => { + if (canceledWhileWaiting) { + return + } + canceledWhileWaiting = true + reservation.release() + if (pendingReservationCancelTokensByLane.get(laneKey) === cancelPendingReservation) { + pendingReservationCancelTokensByLane.delete(laneKey) + } + resolvePendingCancellation() + } + pendingReservationCancelTokensByLane.set(laneKey, cancelPendingReservation) + previousCancelToken?.() + await Promise.race([reservation.waitForClose, pendingCancellation]) + if (pendingReservationCancelTokensByLane.get(laneKey) === cancelPendingReservation) { + pendingReservationCancelTokensByLane.delete(laneKey) + } + if (canceledWhileWaiting) { + return { success: false, error: 'Generation canceled.', canceled: true } + } + } else { + previousCancelToken?.() + } return new Promise((resolve) => { let child: ChildProcess try { @@ -586,6 +702,7 @@ async function runLocalPlan( }) } } catch (error) { + reservation.release() if (error instanceof UnsafeWindowsBatchArgumentsError) { resolve({ success: false, @@ -600,15 +717,13 @@ async function runLocalPlan( }) return } + const owner = reservation.register(child, laneKey) - let stdout = '' - let stderr = '' - let stdoutBytes = 0 - let stderrBytes = 0 + const stdout = new GrowingByteBuffer() + const stderr = new GrowingByteBuffer() let outputLimitExceeded = false let settled = false let canceledByUser = false - const laneKey = localLaneKey(operation, cwd) let cancelToken: (() => void) | null = null let timer: ReturnType | null = null let detachChildListeners = (): void => {} @@ -625,6 +740,8 @@ async function runLocalPlan( if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) { cancelTokensByLane.delete(laneKey) } + stdout.clear() + stderr.clear() resolve(result) } @@ -646,22 +763,20 @@ async function runLocalPlan( }, GENERATION_TIMEOUT_MS) const onStdoutData = (chunk: Buffer): void => { - stdoutBytes += chunk.byteLength - if (stdoutBytes > MAX_AGENT_OUTPUT_BYTES) { + if (stdout.byteLength + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) { outputLimitExceeded = true killProcessTree(child) return } - stdout += chunk.toString('utf-8') + stdout.append(chunk) } const onStderrData = (chunk: Buffer): void => { - stderrBytes += chunk.byteLength - if (stderrBytes > MAX_AGENT_OUTPUT_BYTES) { + if (stderr.byteLength + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) { outputLimitExceeded = true killProcessTree(child) return } - stderr += chunk.toString('utf-8') + stderr.append(chunk) } const onError = (error: Error): void => { const code = (error as NodeJS.ErrnoException).code @@ -679,6 +794,8 @@ async function runLocalPlan( }) } const onClose = (code: number | null): void => { + child.off?.('close', onClose) + owner.release() if (canceledByUser) { finalize({ success: false, error: 'Generation canceled.', canceled: true }) return @@ -692,8 +809,8 @@ async function runLocalPlan( } finalizeFromAgentOutput({ code, - stdout, - stderr, + stdout: stdout.toString(), + stderr: stderr.toString(), label, emptyResultName, finalize, @@ -708,7 +825,6 @@ async function runLocalPlan( child.stdout?.off?.('data', onStdoutData) child.stderr?.off?.('data', onStderrData) child.off?.('error', onError) - child.off?.('close', onClose) } child.stdin?.end(stdinPayload ?? undefined) @@ -876,7 +992,9 @@ export async function generateCommitMessageFromContext( basePrompt, branch: context.branch ?? '(detached)', stagedFiles: context.stagedSummary, - stagedPatch: context.stagedPatch + stagedPatch: context.stagedPatch, + // Why: always pass the key so `{linkedIssue}` never survives as a literal token. + linkedIssue: formatLinkedIssueTemplateValue(context.linkedIssue) }) : buildCommitMessagePrompt(context, params.customPrompt ?? '') const planned = planCommitMessageGeneration(params, prompt) @@ -899,7 +1017,7 @@ export async function generateCommitMessageFromContext( } export function cancelGeneratePullRequestFieldsLocal(cwd: string): void { - cancelTokensByLane.get(localLaneKey('pull-request-fields', cwd))?.() + cancelLocalGenerationLane(localLaneKey('pull-request-fields', cwd)) } function formatPullRequestFieldsGenerationResult( @@ -947,7 +1065,9 @@ export async function generatePullRequestFieldsFromContext( currentBody: context.currentBody, commitSummary: context.commitSummary, changedFiles: context.changeSummary, - patch: context.patch + patch: context.patch, + // Why: always pass the key so `{linkedIssue}` never survives as a literal token. + linkedIssue: formatLinkedIssueTemplateValue(context.linkedIssue) }) : buildPullRequestFieldsPrompt(context, params.customPrompt ?? '') const planned = planCommitMessageGeneration(params, prompt) diff --git a/src/main/text-generation/local-ai-process-budget.ts b/src/main/text-generation/local-ai-process-budget.ts new file mode 100644 index 00000000000..9b51a6629ef --- /dev/null +++ b/src/main/text-generation/local-ai-process-budget.ts @@ -0,0 +1,110 @@ +import type { ChildProcess } from 'node:child_process' + +export const MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS = 8 + +type LocalAiProcessOwner = { + child: ChildProcess + closed: Promise + release: () => void +} + +export type LocalAiProcessReservation = { + waitForClose?: Promise + register: (child: ChildProcess, laneKey?: string) => LocalAiProcessOwner + release: () => void +} + +type ReplacementReservation = { + reservation: LocalAiProcessReservation + activate: () => void +} + +const activeOwners = new Set() +const reservedSlots = new Set() +const ownerByLane = new Map() +const replacementByOwner = new Map() + +export function reserveLocalAiProcess(laneKey?: string): LocalAiProcessReservation | null { + let predecessor: LocalAiProcessOwner | undefined + if (activeOwners.size + reservedSlots.size >= MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) { + predecessor = laneKey ? ownerByLane.get(laneKey) : undefined + if (!predecessor || replacementByOwner.has(predecessor)) { + return null + } + } + + let state: 'waiting' | 'reserved' | 'registered' | 'released' = predecessor + ? 'waiting' + : 'reserved' + let reservation: LocalAiProcessReservation + const activate = (): void => { + if (state !== 'waiting') { + return + } + state = 'reserved' + reservedSlots.add(reservation) + } + reservation = { + waitForClose: predecessor?.closed, + register: (child, ownerLaneKey) => { + if (state !== 'reserved') { + throw new Error('Local AI process reservation is not ready.') + } + reservedSlots.delete(reservation) + state = 'registered' + + let released = false + let resolveClosed = (): void => {} + const closed = new Promise((resolve) => { + resolveClosed = resolve + }) + const owner: LocalAiProcessOwner = { + child, + closed, + release: () => { + if (released) { + return + } + released = true + activeOwners.delete(owner) + if (ownerLaneKey && ownerByLane.get(ownerLaneKey) === owner) { + ownerByLane.delete(ownerLaneKey) + } + const replacement = replacementByOwner.get(owner) + if (replacement) { + replacementByOwner.delete(owner) + // Preserve the claimed slot before `closed` lets the replacement resume. + replacement.activate() + } + resolveClosed() + } + } + activeOwners.add(owner) + if (ownerLaneKey) { + ownerByLane.set(ownerLaneKey, owner) + } + return owner + }, + release: () => { + if (state === 'waiting' && predecessor) { + const replacement = replacementByOwner.get(predecessor) + if (replacement?.reservation === reservation) { + replacementByOwner.delete(predecessor) + } + state = 'released' + return + } + if (state === 'reserved') { + reservedSlots.delete(reservation) + state = 'released' + } + } + } + + if (predecessor) { + replacementByOwner.set(predecessor, { reservation, activate }) + } else { + reservedSlots.add(reservation) + } + return reservation +} diff --git a/src/main/updater-changelog.test.ts b/src/main/updater-changelog.test.ts index f6a348858d0..c9a9d306627 100644 --- a/src/main/updater-changelog.test.ts +++ b/src/main/updater-changelog.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { API_RESPONSE_MAX_BYTES, FetchResponseBodyTooLargeError } from './lib/fetch-response-body' const fetchMock = vi.fn() @@ -226,6 +227,18 @@ describe('fetchChangelog', () => { expect(result).toBeNull() }) + it('rejects an oversized successful Electron net response before reading it', async () => { + fetchMock.mockResolvedValue( + new Response('{', { + headers: { 'content-length': String(API_RESPONSE_MAX_BYTES + 1) } + }) + ) + + await expect(fetchChangelog('1.1.21', '1.1.19')).rejects.toBeInstanceOf( + FetchResponseBodyTooLargeError + ) + }) + it('prefers exact match over fallback when both have rich content', async () => { const entries = makeEntries([ { diff --git a/src/main/updater-changelog.ts b/src/main/updater-changelog.ts index a534075cfc6..ea52d9db4ff 100644 --- a/src/main/updater-changelog.ts +++ b/src/main/updater-changelog.ts @@ -1,6 +1,7 @@ import { net } from 'electron' import type { ChangelogData } from '../shared/types' import { compareVersions } from './updater-fallback' +import { readFetchResponseJsonWithinLimit } from './lib/fetch-response-body' type ChangelogEntry = { version: string @@ -48,7 +49,7 @@ export async function fetchChangelog( if (!res.ok) { return null } - const json: unknown = await res.json() + const json = await readFetchResponseJsonWithinLimit(res) // Why: the JSON endpoint is external and could serve malformed data. // Validate the shape before indexing into it to avoid runtime errors diff --git a/src/main/updater-nudge.ts b/src/main/updater-nudge.ts index 93ef8c1c105..1755c2ec021 100644 --- a/src/main/updater-nudge.ts +++ b/src/main/updater-nudge.ts @@ -1,5 +1,6 @@ import { net } from 'electron' import { compareVersions, isValidVersion } from './updater-fallback' +import { readFetchResponseJsonWithinLimit } from './lib/fetch-response-body' export type NudgeConfig = { id: string @@ -16,7 +17,7 @@ export async function fetchNudge(): Promise { return null } - const json: unknown = await res.json() + const json = await readFetchResponseJsonWithinLimit(res) if (!json || typeof json !== 'object' || Array.isArray(json)) { return null } diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index 21a1f397ff4..6bc5bca20ef 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -1,6 +1,7 @@ import { net } from 'electron' import { parse } from 'yaml' import { compareVersions, isPrereleaseVersion, isValidVersion } from './updater-fallback' +import { readFetchResponseTextWithinLimit } from './lib/fetch-response-body' const ATOM_FEED_URL = 'https://github.com/stablyai/orca/releases.atom' const RELEASES_DOWNLOAD_BASE = 'https://github.com/stablyai/orca/releases/download' @@ -61,7 +62,7 @@ async function fetchReleaseFeedTags(): Promise { if (!res.ok) { return null } - const body = await res.text() + const body = await readFetchResponseTextWithinLimit(res) const tags: ReleaseFeedTag[] = [] for (const match of body.matchAll(TAG_HREF_RE)) { @@ -128,7 +129,7 @@ async function hasReadyPlatformManifest(tag: string): Promise { if (!res.ok) { return false } - const assetNames = getManifestAssetNames(await res.text()) + const assetNames = getManifestAssetNames(await readFetchResponseTextWithinLimit(res)) if (assetNames.length === 0) { return false } diff --git a/src/main/usage-history-file-discovery.ts b/src/main/usage-history-file-discovery.ts new file mode 100644 index 00000000000..f16dfdc1d4d --- /dev/null +++ b/src/main/usage-history-file-discovery.ts @@ -0,0 +1,44 @@ +import { opendir } from 'node:fs/promises' +import { join } from 'node:path' +import type { UsageHistoryScanBudget } from './usage-history-scan-budget' + +export async function walkUsageHistoryJsonlFiles( + rootPath: string, + budget: UsageHistoryScanBudget +): Promise { + const pendingDirectories = [rootPath] + const files: string[] = [] + + while (pendingDirectories.length > 0) { + const directoryPath = pendingDirectories.pop()! + let directory: Awaited> + try { + directory = await opendir(directoryPath) + } catch (error) { + if (directoryPath !== rootPath && isVanishedDirectoryError(error)) { + continue + } + throw error + } + for await (const entry of directory) { + budget.claimDiscoveryEntry() + const fullPath = join(directoryPath, entry.name) + if (entry.isDirectory()) { + budget.claimPath(fullPath) + pendingDirectories.push(fullPath) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + budget.claimFile(fullPath) + files.push(fullPath) + } + } + } + + return files +} + +function isVanishedDirectoryError(error: unknown): boolean { + if (error === null || typeof error !== 'object' || !('code' in error)) { + return false + } + return error.code === 'ENOENT' || error.code === 'ENOTDIR' +} diff --git a/src/main/usage-history-jsonl-reader.test.ts b/src/main/usage-history-jsonl-reader.test.ts new file mode 100644 index 00000000000..df2551b4628 --- /dev/null +++ b/src/main/usage-history-jsonl-reader.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + MAX_USAGE_HISTORY_JSONL_LINE_BYTES, + UsageHistoryJsonlLineCapacityError, + readUsageHistoryJsonlLines +} from './usage-history-jsonl-reader' + +describe('readUsageHistoryJsonlLines', () => { + const temporaryDirectories: string[] = [] + + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) + }) + + async function createPath(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-usage-jsonl-')) + temporaryDirectories.push(directory) + return join(directory, 'history.jsonl') + } + + it('preserves ordinary empty, CRLF, and unterminated records', async () => { + const filePath = await createPath() + await writeFile(filePath, 'first\r\n\nlast') + + const lines: string[] = [] + for await (const line of readUsageHistoryJsonlLines(filePath)) { + lines.push(line) + } + + expect(lines).toEqual(['first', '', 'last']) + }) + + it('accepts a record exactly at the byte limit', async () => { + const filePath = await createPath() + await writeFile(filePath, Buffer.alloc(MAX_USAGE_HISTORY_JSONL_LINE_BYTES, 0x61)) + + const lines: string[] = [] + for await (const line of readUsageHistoryJsonlLines(filePath)) { + lines.push(line) + } + + expect(Buffer.byteLength(lines[0])).toBe(MAX_USAGE_HISTORY_JSONL_LINE_BYTES) + }) + + it('rejects a sparse unterminated record before reading the whole file', async () => { + const filePath = await createPath() + await writeFile(filePath, '') + await truncate(filePath, MAX_USAGE_HISTORY_JSONL_LINE_BYTES + 8 * 1024 * 1024) + + const consume = async (): Promise => { + for await (const _line of readUsageHistoryJsonlLines(filePath)) { + // The oversized record is unterminated, so no line should be yielded. + } + } + + await expect(consume()).rejects.toThrow(UsageHistoryJsonlLineCapacityError) + }) +}) diff --git a/src/main/usage-history-jsonl-reader.ts b/src/main/usage-history-jsonl-reader.ts new file mode 100644 index 00000000000..d9e5870c6ce --- /dev/null +++ b/src/main/usage-history-jsonl-reader.ts @@ -0,0 +1,57 @@ +import { createReadStream } from 'node:fs' + +export const MAX_USAGE_HISTORY_JSONL_LINE_BYTES = 4 * 1024 * 1024 + +export class UsageHistoryJsonlLineCapacityError extends Error { + constructor(readonly limit = MAX_USAGE_HISTORY_JSONL_LINE_BYTES) { + super(`Usage history JSONL record exceeded ${limit} bytes`) + this.name = 'UsageHistoryJsonlLineCapacityError' + } +} + +export async function* readUsageHistoryJsonlLines( + filePath: string, + options: { start?: number; maxLineBytes?: number } = {} +): AsyncGenerator { + const maxLineBytes = options.maxLineBytes ?? MAX_USAGE_HISTORY_JSONL_LINE_BYTES + const stream = createReadStream(filePath, { + start: options.start ?? 0, + highWaterMark: 64 * 1024 + }) + let fragments: Buffer[] = [] + let fragmentBytes = 0 + + for await (const rawChunk of stream) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) + let offset = 0 + while (offset < chunk.length) { + const newlineIndex = chunk.indexOf(0x0a, offset) + const end = newlineIndex === -1 ? chunk.length : newlineIndex + const fragment = chunk.subarray(offset, end) + if (fragment.length > maxLineBytes - fragmentBytes) { + throw new UsageHistoryJsonlLineCapacityError(maxLineBytes) + } + if (fragment.length > 0) { + fragments.push(fragment) + fragmentBytes += fragment.length + } + if (newlineIndex === -1) { + break + } + yield decodeJsonlLine(fragments, fragmentBytes) + fragments = [] + fragmentBytes = 0 + offset = newlineIndex + 1 + } + } + + if (fragmentBytes > 0) { + yield decodeJsonlLine(fragments, fragmentBytes) + } +} + +function decodeJsonlLine(fragments: Buffer[], bytes: number): string { + const line = fragments.length === 1 ? fragments[0] : Buffer.concat(fragments, bytes) + const content = line.at(-1) === 0x0d ? line.subarray(0, -1) : line + return content.toString('utf8') +} diff --git a/src/main/usage-history-scan-budget.test.ts b/src/main/usage-history-scan-budget.test.ts new file mode 100644 index 00000000000..80cff368e22 --- /dev/null +++ b/src/main/usage-history-scan-budget.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_USAGE_HISTORY_DISCOVERY_ENTRIES, + MAX_USAGE_HISTORY_FILES, + MAX_USAGE_HISTORY_OWNERSHIP_KEYS, + MAX_USAGE_HISTORY_RECORDS, + MAX_USAGE_HISTORY_RETAINED_BYTES, + UsageHistoryScanBudget, + UsageHistoryScanCapacityError +} from './usage-history-scan-budget' + +describe('UsageHistoryScanBudget', () => { + it('publishes the production scan limits', () => { + expect({ + files: MAX_USAGE_HISTORY_FILES, + discoveryEntries: MAX_USAGE_HISTORY_DISCOVERY_ENTRIES, + records: MAX_USAGE_HISTORY_RECORDS, + ownershipKeys: MAX_USAGE_HISTORY_OWNERSHIP_KEYS, + retainedBytes: MAX_USAGE_HISTORY_RETAINED_BYTES + }).toEqual({ + files: 200_000, + discoveryEntries: 1_000_000, + records: 100_000, + ownershipKeys: 100_000, + retainedBytes: 256 * 1024 * 1024 + }) + }) + + it('accepts exact limits and fails closed before each collection can grow past them', () => { + const files = new UsageHistoryScanBudget({ files: 2, retainedBytes: 10_000 }) + files.claimFile('a') + files.claimFile('b') + expect(() => files.claimFile('c')).toThrowError( + expect.objectContaining({ resource: 'files', limit: 2 }) + ) + + const entries = new UsageHistoryScanBudget({ discoveryEntries: 2 }) + entries.claimDiscoveryEntry() + entries.claimDiscoveryEntry() + expect(() => entries.claimDiscoveryEntry()).toThrow(UsageHistoryScanCapacityError) + + const records = new UsageHistoryScanBudget({ records: 2, retainedBytes: 10_000 }) + records.claimRecord(1) + records.claimRecord(1) + expect(() => records.claimRecord(1)).toThrowError( + expect.objectContaining({ resource: 'records', limit: 2 }) + ) + + const keys = new UsageHistoryScanBudget({ ownershipKeys: 2, retainedBytes: 10_000 }) + keys.claimOwnershipKey('a') + keys.claimOwnershipKey('b') + expect(() => keys.claimOwnershipKey('c')).toThrowError( + expect.objectContaining({ resource: 'ownershipKeys', limit: 2 }) + ) + + const bytes = new UsageHistoryScanBudget({ retainedBytes: 2 }) + bytes.claimRetainedBytes(2) + expect(() => bytes.claimRetainedBytes(1)).toThrowError( + expect.objectContaining({ resource: 'retainedBytes', limit: 2 }) + ) + }) + + it('charges conservative collection overhead for many tiny values', () => { + const records = new UsageHistoryScanBudget({ records: 100, retainedBytes: 10 * 1024 }) + for (let index = 0; index < 10; index++) { + records.claimRecord(0) + } + expect(() => records.claimRecord(0)).toThrowError( + expect.objectContaining({ resource: 'retainedBytes', limit: 10 * 1024 }) + ) + + const paths = new UsageHistoryScanBudget({ files: 100, retainedBytes: 1_940 }) + for (let index = 0; index < 10; index++) { + paths.claimFile('a') + } + expect(() => paths.claimFile('b')).toThrowError( + expect.objectContaining({ resource: 'retainedBytes', limit: 1_940 }) + ) + }) +}) diff --git a/src/main/usage-history-scan-budget.ts b/src/main/usage-history-scan-budget.ts new file mode 100644 index 00000000000..d363da15f9b --- /dev/null +++ b/src/main/usage-history-scan-budget.ts @@ -0,0 +1,111 @@ +export const MAX_USAGE_HISTORY_FILES = 200_000 +export const MAX_USAGE_HISTORY_DISCOVERY_ENTRIES = 1_000_000 +export const MAX_USAGE_HISTORY_RECORDS = 100_000 +export const MAX_USAGE_HISTORY_OWNERSHIP_KEYS = 100_000 +export const MAX_USAGE_HISTORY_RETAINED_BYTES = 256 * 1024 * 1024 + +const FILE_COLLECTION_ENTRY_BYTES = 160 +const OWNERSHIP_COLLECTION_ENTRY_BYTES = 160 +const RECORD_PIPELINE_BYTES = 1024 +const PROJECTION_ENTRY_BYTES = 256 +const STRING_HEADER_BYTES = 32 + +type UsageHistoryScanLimits = { + files: number + discoveryEntries: number + records: number + ownershipKeys: number + retainedBytes: number +} + +const DEFAULT_LIMITS: UsageHistoryScanLimits = { + files: MAX_USAGE_HISTORY_FILES, + discoveryEntries: MAX_USAGE_HISTORY_DISCOVERY_ENTRIES, + records: MAX_USAGE_HISTORY_RECORDS, + ownershipKeys: MAX_USAGE_HISTORY_OWNERSHIP_KEYS, + retainedBytes: MAX_USAGE_HISTORY_RETAINED_BYTES +} + +export class UsageHistoryScanCapacityError extends Error { + constructor( + readonly resource: keyof UsageHistoryScanLimits, + readonly limit: number + ) { + super(`Usage history scan exceeded ${limit} ${resource}`) + this.name = 'UsageHistoryScanCapacityError' + } +} + +export class UsageHistoryScanBudget { + private readonly limits: UsageHistoryScanLimits + private readonly used: UsageHistoryScanLimits = { + files: 0, + discoveryEntries: 0, + records: 0, + ownershipKeys: 0, + retainedBytes: 0 + } + + constructor(limits: Partial = {}) { + this.limits = { ...DEFAULT_LIMITS, ...limits } + } + + claimFile(path: string): void { + this.claim('files', 1) + this.claimPath(path) + } + + claimDiscoveryEntry(): void { + this.claim('discoveryEntries', 1) + } + + claimRecord(retainedBytes: number): void { + this.claim('records', 1) + this.claim('retainedBytes', RECORD_PIPELINE_BYTES + retainedBytes) + } + + claimRecords(count: number): void { + this.claim('records', count) + this.claim('retainedBytes', RECORD_PIPELINE_BYTES * count) + } + + claimRetainedBytes(bytes: number): void { + this.claim('retainedBytes', bytes) + } + + claimPath(path: string): void { + this.claim('retainedBytes', FILE_COLLECTION_ENTRY_BYTES + getUsageHistoryRetainedBytes([path])) + } + + claimProjection(retainedStringBytes: number): void { + this.claim('retainedBytes', PROJECTION_ENTRY_BYTES + retainedStringBytes) + } + + claimOwnershipKey(key: string): void { + this.claim('ownershipKeys', 1) + this.claim( + 'retainedBytes', + OWNERSHIP_COLLECTION_ENTRY_BYTES + getUsageHistoryRetainedBytes([key]) + ) + } + + private claim(resource: keyof UsageHistoryScanLimits, amount: number): void { + if (!Number.isSafeInteger(amount) || amount < 0) { + throw new UsageHistoryScanCapacityError(resource, this.limits[resource]) + } + if (amount > this.limits[resource] - this.used[resource]) { + throw new UsageHistoryScanCapacityError(resource, this.limits[resource]) + } + this.used[resource] += amount + } +} + +export function getUsageHistoryRetainedBytes(values: readonly (string | null)[]): number { + let bytes = 0 + for (const value of values) { + if (value !== null) { + bytes += STRING_HEADER_BYTES + value.length * 2 + } + } + return bytes +} diff --git a/src/main/usage-projection-state-file.test.ts b/src/main/usage-projection-state-file.test.ts new file mode 100644 index 00000000000..9dd28c4a2d4 --- /dev/null +++ b/src/main/usage-projection-state-file.test.ts @@ -0,0 +1,96 @@ +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + truncateSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + readUsageProjectionStateFile, + serializeUsageProjectionState, + UsageProjectionStateCapacityError, + writeUsageProjectionStateFile, + writeUsageProjectionStateFileWithRecovery +} from './usage-projection-state-file' + +const tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('usage projection state files', () => { + it('accepts exact serialized bytes and rejects the next byte', () => { + expect(serializeUsageProjectionState('x'.repeat(30), 32)).toHaveLength(32) + expect(() => serializeUsageProjectionState('x'.repeat(31), 32)).toThrow( + UsageProjectionStateCapacityError + ) + }) + + it('checks the file size before allocating or decoding it', () => { + const root = createTempRoot() + const path = join(root, 'usage.json') + writeFileSync(path, '{"ok":true}') + expect(readUsageProjectionStateFile(path, 11)).toBe('{"ok":true}') + + truncateSync(path, 12) + expect(() => readUsageProjectionStateFile(path, 11)).toThrow('File too large') + expect(readUsageProjectionStateFile(join(root, 'absent.json'), 11)).toBeNull() + }) + + it('rejects structurally amplified caches before parsing them', () => { + const root = createTempRoot() + const path = join(root, 'usage.json') + const json = '{"rows":[{},{}]}' + writeFileSync(path, json) + + expect(() => + readUsageProjectionStateFile(path, Buffer.byteLength(json), { + structuralTokens: 7, + nestingDepth: 3 + }) + ).toThrow('JSON structure') + }) + + it('writes atomically and preserves ordinary JSON semantics', () => { + const root = createTempRoot() + const path = join(root, 'nested', 'usage.json') + const state = { enabled: true, rows: [{ id: 'one' }, { id: 'two' }] } + + writeUsageProjectionStateFile(path, state, 1024) + + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual(state) + expect(readdirSync(join(root, 'nested'))).toEqual(['usage.json']) + }) + + it('replaces an oversized rebuildable projection with a bounded error state', () => { + const root = createTempRoot() + const path = join(root, 'usage.json') + const recovered = writeUsageProjectionStateFileWithRecovery<{ + enabled: boolean + rows: string | unknown[] + error?: string + }>( + path, + { enabled: true, rows: 'x'.repeat(200) }, + (error) => ({ enabled: true, rows: [], error: error.message }), + 160 + ) + + expect(recovered).toMatchObject({ enabled: true, rows: [] }) + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual(recovered) + expect(readdirSync(root)).toEqual(['usage.json']) + }) +}) + +function createTempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-usage-projection-')) + tempRoots.push(root) + return root +} diff --git a/src/main/usage-projection-state-file.ts b/src/main/usage-projection-state-file.ts new file mode 100644 index 00000000000..412237a9082 --- /dev/null +++ b/src/main/usage-projection-state-file.ts @@ -0,0 +1,98 @@ +import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { + JsonStringifyByteLimitError, + stringifyJsonWithinByteLimit +} from '../shared/memory-safety/node-bounded-json-stringify' +import { readNodeFileSyncWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import { + assertJsonTextStructureWithinLimits, + type JsonTextStructureLimits +} from '../shared/memory-safety/json-text-structure-limit' + +export const MAX_USAGE_PROJECTION_STATE_FILE_BYTES = 64 * 1024 * 1024 +export const USAGE_PROJECTION_STATE_JSON_LIMITS: JsonTextStructureLimits = { + structuralTokens: 1_000_000, + nestingDepth: 256 +} + +export class UsageProjectionStateCapacityError extends Error { + constructor(readonly maxBytes = MAX_USAGE_PROJECTION_STATE_FILE_BYTES) { + super(`Usage analytics cache exceeds ${maxBytes} bytes and must be rebuilt.`) + this.name = 'UsageProjectionStateCapacityError' + } +} + +export function readUsageProjectionStateFile( + filePath: string, + maxBytes = MAX_USAGE_PROJECTION_STATE_FILE_BYTES, + structureLimits: JsonTextStructureLimits = USAGE_PROJECTION_STATE_JSON_LIMITS +): string | null { + try { + const serialized = readNodeFileSyncWithinLimit(filePath, maxBytes).buffer.toString('utf8') + assertJsonTextStructureWithinLimits(serialized, structureLimits) + return serialized + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } +} + +export function serializeUsageProjectionState( + state: unknown, + maxBytes = MAX_USAGE_PROJECTION_STATE_FILE_BYTES +): string { + try { + return stringifyJsonWithinByteLimit(state, maxBytes).serialized + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + throw new UsageProjectionStateCapacityError(maxBytes) + } + throw error + } +} + +export function writeUsageProjectionStateFile( + filePath: string, + state: unknown, + maxBytes = MAX_USAGE_PROJECTION_STATE_FILE_BYTES +): void { + const payload = serializeUsageProjectionState(state, maxBytes) + mkdirSync(dirname(filePath), { recursive: true }) + const tmpFile = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + let renamed = false + try { + writeFileSync(tmpFile, payload, 'utf8') + renameSync(tmpFile, filePath) + renamed = true + } finally { + if (!renamed) { + try { + unlinkSync(tmpFile) + } catch { + // The primary write error is more useful than a best-effort cleanup failure. + } + } + } +} + +export function writeUsageProjectionStateFileWithRecovery( + filePath: string, + state: T, + recover: (error: UsageProjectionStateCapacityError) => T, + maxBytes = MAX_USAGE_PROJECTION_STATE_FILE_BYTES +): T { + try { + writeUsageProjectionStateFile(filePath, state, maxBytes) + return state + } catch (error) { + if (!(error instanceof UsageProjectionStateCapacityError)) { + throw error + } + const recovered = recover(error) + writeUsageProjectionStateFile(filePath, recovered, maxBytes) + return recovered + } +} diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 1ae323607c6..31b004c2bfc 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -202,17 +202,15 @@ describe('attachMainWindowServices', () => { it('reloads the app renderer through main and marks expected renderer teardown', async () => { const onBeforeRendererReload = vi.fn() const mainWindow = createMainWindow() + const store = createStore() + const runtime = createRuntime() - attachMainWindowServices( - mainWindow as never, - createStore(), - createRuntime() as never, - undefined, - undefined, - { onBeforeRendererReload } - ) + attachMainWindowServices(mainWindow as never, store, runtime as never, undefined, undefined, { + onBeforeRendererReload + }) expect(removeHandlerMock).toHaveBeenCalledWith('app:reload') + expect(registerRepoHandlersMock).toHaveBeenCalledWith(mainWindow, store, runtime) const reloadHandler = handleMock.mock.calls.find(([channel]) => channel === 'app:reload')?.[1] expect(reloadHandler).toBeTypeOf('function') diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0a481b209bc..cc9ea9f8280 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -87,7 +87,7 @@ export function attachMainWindowServices( } ): void { registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload) - registerRepoHandlers(mainWindow, store) + registerRepoHandlers(mainWindow, store, runtime) registerWorktreeHandlers(mainWindow, store, runtime) // Why: repo/settings mutations resync watchers through this attached main-window context. setWorktreeBaseDirectoryWatcherSyncContext(store, mainWindow) diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index b1465beb82d..63d089584d0 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -13,6 +13,7 @@ const { childStdinEndMock, resolveAuthorizedPathMock, fsMkdirMock, + fsOpendirMock, fsReaddirMock, fsRmMock, fsWriteFileMock, @@ -46,6 +47,7 @@ const { }), resolveAuthorizedPathMock: vi.fn(), fsMkdirMock: vi.fn(), + fsOpendirMock: vi.fn(), fsReaddirMock: vi.fn(), fsRmMock: vi.fn(), fsWriteFileMock: vi.fn(), @@ -69,7 +71,7 @@ vi.mock('node:child_process', () => ({ vi.mock('node:fs/promises', () => ({ mkdir: fsMkdirMock, - readdir: fsReaddirMock, + opendir: fsOpendirMock, rm: fsRmMock, open: fsOpenMock, stat: fsStatMock, @@ -194,6 +196,13 @@ describe('registerClipboardHandlers', () => { resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) fsMkdirMock.mockReset() fsMkdirMock.mockResolvedValue(undefined) + fsOpendirMock.mockReset() + fsOpendirMock.mockImplementation(async () => ({ + async *[Symbol.asyncIterator]() { + yield* await fsReaddirMock() + }, + close: vi.fn().mockResolvedValue(undefined) + })) fsReaddirMock.mockReset() fsReaddirMock.mockResolvedValue([]) fsRmMock.mockReset() diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 052fce31ef3..fdb63eb11a0 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { Dirent } from 'node:fs' -import { mkdir, readdir, rm, stat } from 'node:fs/promises' +import { mkdir, opendir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { app } from 'electron' @@ -17,6 +16,7 @@ type RemoteClipboardFileDeps = Omit const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-' +const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) @@ -81,30 +81,44 @@ export async function writeRemoteFileToClipboard({ export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { const tempRoot = app.getPath('temp') - let entries: Dirent[] + let directory: Awaited> try { - entries = await readdir(tempRoot, { withFileTypes: true }) + directory = await opendir(tempRoot) } catch { return } - await Promise.all( - entries.map(async (entry) => { + const pending = new Set>() + try { + for await (const entry of directory) { if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) { - return + continue } const tempDir = join(tempRoot, entry.name) - try { - const tempStats = await stat(tempDir) - if (nowMs - tempStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { - return - } - await rm(tempDir, { recursive: true, force: true }) - } catch { - // Why: stale staged SSH files should not make startup cleanup noisy. + const cleanup = cleanupRemoteClipboardDirectory(tempDir, nowMs) + pending.add(cleanup) + void cleanup.finally(() => pending.delete(cleanup)) + if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { + await Promise.race(pending) } - }) - ) + } + } catch { + // A partial best-effort startup sweep is sufficient. + } finally { + await directory.close().catch(() => undefined) + } + await Promise.all(pending) +} + +async function cleanupRemoteClipboardDirectory(tempDir: string, nowMs: number): Promise { + try { + const tempStats = await stat(tempDir) + if (nowMs - tempStats.mtimeMs >= REMOTE_CLIPBOARD_FILE_TTL_MS) { + await rm(tempDir, { recursive: true, force: true }) + } + } catch { + // Why: stale staged SSH files should not make startup cleanup noisy. + } } function sanitizeLocalClipboardFilename(remoteBasename: string): string { diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 50643514e4b..6076a505582 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -523,8 +523,7 @@ describe('createMainWindow', () => { // dvh root — via the emulated viewport, which leaves the deadlock-prone frame untouched. expect(webContents.enableDeviceEmulation).toHaveBeenCalledWith({ screenPosition: 'desktop', - screenSize: { width: 1200, height: 800 }, - viewPosition: { x: 0, y: 0 }, + screenSize: { width: 0, height: 0 }, deviceScaleFactor: 2.25, viewSize: { width: 1200, height: 800 }, scale: 1 @@ -642,8 +641,7 @@ describe('createMainWindow', () => { expect(browserWindowInstance.setSize).not.toHaveBeenCalled() expect(webContents.enableDeviceEmulation).toHaveBeenCalledWith({ screenPosition: 'desktop', - screenSize: { width: 1200, height: 800 }, - viewPosition: { x: 0, y: 0 }, + screenSize: { width: 0, height: 0 }, deviceScaleFactor: 2.25, viewSize: { width: 1200, height: 800 }, scale: 1 diff --git a/src/main/window/renderer-viewport-reflow.test.ts b/src/main/window/renderer-viewport-reflow.test.ts index c43b50228b0..a9c83595e51 100644 --- a/src/main/window/renderer-viewport-reflow.test.ts +++ b/src/main/window/renderer-viewport-reflow.test.ts @@ -6,6 +6,10 @@ vi.mock('electron', () => ({ screen: { getDisplayMatching: vi.fn(() => ({ scaleFactor: scaleFactorMock.value })) } })) +import { + clearCrashBreadcrumbsForTest, + getCrashBreadcrumbSnapshot +} from '../crash-reporting/crash-breadcrumb-store' import { reflowRendererViewport, VIEWPORT_REFLOW_RESTORE_ATTEMPTS, @@ -33,6 +37,7 @@ describe('reflowRendererViewport', () => { beforeEach(() => { vi.useFakeTimers() scaleFactorMock.value = 1 + clearCrashBreadcrumbsForTest() }) afterEach(() => { vi.useRealTimers() @@ -55,8 +60,7 @@ describe('reflowRendererViewport', () => { vi.advanceTimersByTime(0) expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledWith({ screenPosition: 'desktop', - screenSize: { width: 1200, height: 800 }, - viewPosition: { x: 0, y: 0 }, + screenSize: { width: 0, height: 0 }, deviceScaleFactor: 1.25, viewSize: { width: 1200, height: 800 }, scale: 1 @@ -76,10 +80,22 @@ describe('reflowRendererViewport', () => { const [params] = (window.webContents.enableDeviceEmulation as ReturnType).mock .calls[0] as [Electron.Parameters] expect(params.viewSize).toEqual({ width: 1200, height: 800 }) - expect(params.screenSize).toEqual({ width: 1200, height: 800 }) expect(params.scale).toBe(1) }) + // Why: Blink applies screenSize/viewPosition ahead of the desktop branch, so overriding them + // moves screen.width and window.screenX for the whole hold — a context menu opened mid-reflow + // would land at the wrong coordinates. Only the scale factor may move. + it('leaves screen geometry and window position untouched while emulating', () => { + const window = createWindow() + reflowRendererViewport(window) + vi.advanceTimersByTime(0) + const [params] = (window.webContents.enableDeviceEmulation as ReturnType).mock + .calls[0] as [Electron.Parameters] + expect(params.screenSize).toEqual({ width: 0, height: 0 }) + expect(params).not.toHaveProperty('viewPosition') + }) + // Why: a constant scale factor would equal the real one on a 1.25x display and reflow nothing. it('offsets from the display the window is actually on', () => { scaleFactorMock.value = 1.25 @@ -222,6 +238,27 @@ describe('reflowRendererViewport', () => { expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2) }) + it('leaves evidence when it gives up with the viewport still emulated', () => { + const window = createWindow() + vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => { + throw new Error('always fails') + }) + reflowRendererViewport(window) + vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS * (VIEWPORT_REFLOW_RESTORE_ATTEMPTS + 4)) + + expect(getCrashBreadcrumbSnapshot().map((crumb) => crumb.name)).toEqual([ + 'viewport_reflow_restore_failed' + ]) + }) + + it('records nothing when the restore succeeds', () => { + const window = createWindow() + reflowRendererViewport(window) + vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS * (VIEWPORT_REFLOW_RESTORE_ATTEMPTS + 4)) + + expect(getCrashBreadcrumbSnapshot()).toEqual([]) + }) + it('abandons the restore once the webContents is gone', () => { const window = createWindow() vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => { diff --git a/src/main/window/renderer-viewport-reflow.ts b/src/main/window/renderer-viewport-reflow.ts index 630a749935d..e4d7450229f 100644 --- a/src/main/window/renderer-viewport-reflow.ts +++ b/src/main/window/renderer-viewport-reflow.ts @@ -1,5 +1,6 @@ import { screen } from 'electron' import type { BrowserWindow } from 'electron' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' // Why: long enough for the emulated scale factor to reach the renderer and drive a relayout. export const VIEWPORT_REFLOW_SETTLE_MS = 32 @@ -52,16 +53,19 @@ export function reflowRendererViewport(window: BrowserWindow): void { // Why: emulating the current factor is a no-op, so offset from this window's own display // rather than a constant, which would match on a 1.25x screen and reflow nothing. const realScaleFactor = screen.getDisplayMatching(window.getBounds()).scaleFactor || 1 + // Why: Blink reads screenSize/viewPosition before the desktop branch, so 'desktop' does + // not make them inert despite the mobile-only docs. Passing the content size and 0,0 + // moved screen.width and window.screenX for the 32ms hold, misplacing anything that + // translates screen coords (the BrowserPane context menu). Empty screenSize means "no + // override", and an omitted viewPosition stays nullopt so the real position survives -- + // Electron's types require the key, but its converter only assigns when present. window.webContents.enableDeviceEmulation({ screenPosition: 'desktop', - // Why: Electron types every field as required; screenSize is mobile-only and scale is - // the emulated-view zoom, so both carry their documented no-op defaults. - screenSize: viewSize, - viewPosition: { x: 0, y: 0 }, + screenSize: { width: 0, height: 0 }, deviceScaleFactor: realScaleFactor + VIEWPORT_REFLOW_SCALE_DELTA, viewSize, scale: 1 - }) + } as Parameters[0]) emulating = true } catch { // Why: a teardown race can destroy the webContents mid-call; nothing was applied. @@ -90,6 +94,10 @@ function restoreRealViewport(window: BrowserWindow, attempt: number): void { // Why: the webContents is still alive, so the renderer is stuck at the emulated scale; // keep retrying and hold the latch so no second cycle stacks on the unrestored state. if (attempt >= VIEWPORT_REFLOW_RESTORE_ATTEMPTS) { + // Why: releasing the latch beats pinning it — a later reveal can still restore. But the + // renderer is stranded at the wrong scale factor until one happens, so leave evidence + // rather than let a permanently-emulated viewport look like a rendering bug. + recordCrashBreadcrumb('viewport_reflow_restore_failed', { attempts: attempt + 1 }) activeViewportReflows.delete(window) return } diff --git a/src/main/windows-pty-root-identity.test.ts b/src/main/windows-pty-root-identity.test.ts index d6852b91d98..c4be64e27fd 100644 --- a/src/main/windows-pty-root-identity.test.ts +++ b/src/main/windows-pty-root-identity.test.ts @@ -36,6 +36,14 @@ describe('classifyWindowsTreeKillTarget', () => { expect(classifyWindowsTreeKillTarget(4242, rows, ORCA_PID)).toBe('own') }) + it('documents that a recycled PID under another Orca pane still classifies as own', () => { + // Dead PTY root 4242 recycled as a tool under a different pane's agent tree. + // Ancestry still reaches us, so taskkill is allowed — wrong process, own tree. + // Closing this needs spawn-time CreationDate / Job Object (#10680). + const rows = [link(ORCA_PID, 900), link(7000, ORCA_PID), link(7100, 7000), link(4242, 7100)] + expect(classifyWindowsTreeKillTarget(4242, rows, ORCA_PID)).toBe('own') + }) + it('rejects a recycled pid whose ancestry never reaches this process', () => { const rows = [...SYSTEM_CHAIN, link(ORCA_PID, 900), link(4242, 900)] expect(classifyWindowsTreeKillTarget(4242, rows, ORCA_PID)).toBe('foreign') @@ -100,21 +108,21 @@ describe('verifyWindowsTreeKillTarget', () => { ).resolves.toBe('foreign') }) - it('falls open to unknown when both Windows process probes are unavailable', async () => { + it('returns unknown when both Windows process probes are unavailable', async () => { const readRows = vi.fn().mockResolvedValue(null) await expect( verifyWindowsTreeKillTarget(4242, { readRows, ownerPid: ORCA_PID, platform: 'win32' }) ).resolves.toBe('unknown') }) - it('falls open to unknown when the process query rejects', async () => { + it('returns unknown when the process query rejects', async () => { const readRows = vi.fn().mockRejectedValue(new Error('powershell missing')) await expect( verifyWindowsTreeKillTarget(4242, { readRows, ownerPid: ORCA_PID, platform: 'win32' }) ).resolves.toBe('unknown') }) - it('falls open to unknown when the query throws synchronously', async () => { + it('returns unknown when the query throws synchronously', async () => { const readRows = vi.fn(() => { throw new Error('spawn EPERM') }) diff --git a/src/main/windows-pty-root-identity.ts b/src/main/windows-pty-root-identity.ts index e8cc16c45a0..b2f6ba43e81 100644 --- a/src/main/windows-pty-root-identity.ts +++ b/src/main/windows-pty-root-identity.ts @@ -5,10 +5,10 @@ import { queryWindowsProcessRowsFresh } from './providers/windows-foreground-pro * subtree membership, not root identity: a recycled PID that lands on any other * Orca descendant also reads `own`. It bounds the blast radius of a bad * `taskkill /T /F` to our own tree; it does not prove we spawned this PTY. - * - `own`: ancestry reaches us, so the tree is ours to kill. + * - `own`: ancestry reaches us, so the tree is eligible for guarded teardown. * - `absent`: the PID is gone; `taskkill` would no-op anyway. * - `foreign`: the PID resolves to a process we did not start (PID recycle). - * - `unknown`: no usable evidence; callers keep their prior behavior. + * - `unknown`: no usable evidence; callers must not force-kill the tree. */ export type WindowsTreeKillTarget = 'own' | 'absent' | 'foreign' | 'unknown' @@ -28,11 +28,12 @@ export type WindowsProcessLinkReader = () => Promise ({ - listRepoWorktreesMock: vi.fn(), - getLocalProjectWorktreeGitOptionsMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn(), - getSshGitProviderMock: vi.fn() -})) +const { listRepoWorktreesMock, getSshFilesystemProviderMock, getSshGitProviderMock } = vi.hoisted( + () => ({ + listRepoWorktreesMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn(), + getSshGitProviderMock: vi.fn() + }) +) vi.mock('./repo-worktrees', () => ({ createFolderWorktree: (repo: Repo) => ({ @@ -36,10 +32,6 @@ vi.mock('./providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) -vi.mock('./project-runtime-git-options', () => ({ - getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock -})) - import { analyzeWorkspaceSpace, WorkspaceSpaceScanCancelledError } from './workspace-space-analysis' function createStore(repos: Repo[]): Store { @@ -66,7 +58,6 @@ describe('analyzeWorkspaceSpace', () => { vi.setSystemTime(new Date('2026-05-14T12:00:00Z')) tempDir = await mkdtemp(join(tmpdir(), 'orca-space-')) listRepoWorktreesMock.mockReset() - getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) getSshFilesystemProviderMock.mockReset() getSshGitProviderMock.mockReset() }) @@ -240,71 +231,6 @@ describe('analyzeWorkspaceSpace', () => { expect(listRepoWorktreesMock).not.toHaveBeenCalled() }) - it('aborts every in-flight local worktree list when the scan is cancelled', async () => { - const repos: Repo[] = [ - { - id: 'repo-1', - path: join(tempDir!, 'repo-1'), - displayName: 'one', - badgeColor: '#000', - addedAt: 0 - }, - { - id: 'repo-2', - path: join(tempDir!, 'repo-2'), - displayName: 'two', - badgeColor: '#000', - addedAt: 0 - } - ] - const capturedSignals: AbortSignal[] = [] - let markBothStarted!: () => void - const bothStarted = new Promise((resolve) => { - markBothStarted = resolve - }) - listRepoWorktreesMock.mockImplementation((_repo: Repo, options?: { signal?: AbortSignal }) => { - const signal = options?.signal - if (!signal) { - throw new Error('expected cancellation signal') - } - capturedSignals.push(signal) - if (capturedSignals.length === repos.length) { - markBothStarted() - } - return new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }) - }) - }) - const controller = new AbortController() - - const scan = analyzeWorkspaceSpace(createStore(repos), { signal: controller.signal }) - await bothStarted - controller.abort() - - await expect(scan).rejects.toBeInstanceOf(WorkspaceSpaceScanCancelledError) - expect(capturedSignals).toHaveLength(2) - expect(capturedSignals.every((signal) => signal.aborted)).toBe(true) - }) - - it('routes local worktree listing through the selected WSL distro', async () => { - const repo: Repo = { - id: 'repo-1', - path: tempDir!, - displayName: 'orca', - badgeColor: '#000', - addedAt: 0 - } - getLocalProjectWorktreeGitOptionsMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - listRepoWorktreesMock.mockResolvedValue([]) - - await analyzeWorkspaceSpace(createStore([repo])) - - expect(listRepoWorktreesMock).toHaveBeenCalledWith(repo, { - wslDistro: 'Ubuntu', - signal: undefined - }) - }) - it('isolates missing worktrees as row-level scan failures', async () => { const root = tempDir! const repoPath = join(root, 'repo') diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 4a7d8a73304..c8307b8c08d 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -1,30 +1,38 @@ /* eslint-disable max-lines -- Why: this module keeps local and SSH directory-walk semantics paired so reclaimable-byte, symlink, and partial-failure behavior cannot drift. */ -import { lstat, readdir } from 'node:fs/promises' +import { lstat, opendir } from 'node:fs/promises' import { execFile } from 'node:child_process' import { posix, win32 } from 'node:path' import { platform } from 'node:process' import type { Dirent } from 'node:fs' import type { Store } from './persistence' import { isFolderRepo } from '../shared/repo-kind' -import type { GitWorktreeInfo, Repo, Worktree } from '../shared/types' +import type { DirEntry, GitWorktreeInfo, Repo, Worktree } from '../shared/types' import type { WorkspaceSpaceAnalysis, WorkspaceSpaceDirectoryScanResult, WorkspaceSpaceItem, - WorkspaceSpaceItemKind, WorkspaceSpaceRepoSummary, WorkspaceSpaceScanProgress, WorkspaceSpaceScanStatus, WorkspaceSpaceWorktree } from '../shared/workspace-space-types' import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' +import { mapWithConcurrency } from '../shared/map-with-concurrency' +import { + scanWorkspaceSpaceEntryTree, + type WorkspaceSpaceEntryScan +} from '../shared/workspace-space-entry-traversal' +import { + collectWorkspaceSpaceDirectoryEntries, + createWorkspaceSpaceScanBudget, + WorkspaceSpaceScanCapacityError +} from '../shared/workspace-space-scan-budget' import type { IFilesystemProvider } from './providers/types' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' import { mergeWorktree } from './ipc/worktree-logic' -import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' const WORKTREE_SCAN_CONCURRENCY = 3 const LOCAL_FS_CONCURRENCY = 48 @@ -32,16 +40,7 @@ const REMOTE_FS_CONCURRENCY = 10 const DU_TIMEOUT_MS = 120_000 const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 -type AsyncLimiter = (task: () => Promise) => Promise - -type ScanStats = { - name: string - path: string - kind: WorkspaceSpaceItemKind - sizeBytes: number - skippedEntryCount: number - children?: ScanStats[] -} +type ScanStats = WorkspaceSpaceEntryScan type WorktreeListResult = | { ok: true; worktrees: GitWorktreeInfo[] } @@ -87,67 +86,6 @@ function isRelayMethodNotFoundError(error: unknown): boolean { return (error as { code?: unknown }).code === -32601 } -function createAsyncLimiter(maxConcurrent: number, signal?: AbortSignal): AsyncLimiter { - let active = 0 - const queue: { resolve: () => void; reject: (error: Error) => void }[] = [] - - const acquire = async (): Promise => { - throwIfAborted(signal) - if (active < maxConcurrent) { - active += 1 - return - } - await new Promise((resolve, reject) => { - let onAbort: (() => void) | null = null - const waiter = { - resolve: () => { - if (onAbort) { - signal?.removeEventListener('abort', onAbort) - } - resolve() - }, - reject - } - onAbort = () => { - const index = queue.indexOf(waiter) - if (index !== -1) { - queue.splice(index, 1) - } - reject(new WorkspaceSpaceScanCancelledError()) - } - queue.push(waiter) - if (signal) { - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) { - onAbort() - } - } - }) - throwIfAborted(signal) - active += 1 - } - - return async (task: () => Promise): Promise => { - await acquire() - try { - return await task() - } finally { - active -= 1 - const next = queue.shift() - next?.resolve() - } - } -} - -async function mapLimit( - items: readonly T[], - maxConcurrent: number, - mapper: (item: T) => Promise -): Promise { - const limit = createAsyncLimiter(maxConcurrent) - return Promise.all(items.map((item) => limit(() => mapper(item)))) -} - function looksLikeWindowsPath(pathValue: string): boolean { return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') } @@ -356,195 +294,73 @@ function createScannedWorktreeRow( async function scanLocalEntry( entryPath: string, name: string, - limit: AsyncLimiter, signal?: AbortSignal ): Promise { - throwIfAborted(signal) - const stats = await limit(() => lstat(entryPath)) - throwIfAborted(signal) - - if (stats.isSymbolicLink()) { - return { - name, - path: entryPath, - kind: 'symlink', - // Why: symlink targets may be shared outside the worktree. Counting the - // link itself reflects what deleting this worktree can actually reclaim. - sizeBytes: stats.size, - skippedEntryCount: 0 - } - } - - if (!stats.isDirectory()) { - return { - name, - path: entryPath, - kind: 'file', - sizeBytes: stats.size, - skippedEntryCount: 0 - } - } - - let entries: Dirent[] - try { - entries = await limit(() => readdir(entryPath, { withFileTypes: true })) - } catch { - return { - name, - path: entryPath, - kind: 'directory', - sizeBytes: stats.size, - skippedEntryCount: 1 - } - } - - const childStats = await Promise.all( - entries.map(async (entry): Promise => { - try { - return await scanLocalEntry( - joinFilesystemPath(entryPath, entry.name), - entry.name, - limit, - signal - ) - } catch (error) { - if (error instanceof WorkspaceSpaceScanCancelledError) { - throw error - } - return null + return scanWorkspaceSpaceEntryTree({ + rootPath: entryPath, + rootName: name, + concurrency: LOCAL_FS_CONCURRENCY, + signal, + entryName: (entry) => entry.name, + joinPath: joinFilesystemPath, + classifyEntry: async (path) => { + const stats = await lstat(path) + throwIfAborted(signal) + if (stats.isSymbolicLink()) { + return { kind: 'symlink', sizeBytes: stats.size } } - }) - ) - - let sizeBytes = stats.size - let skippedEntryCount = 0 - for (const child of childStats) { - if (!child) { - skippedEntryCount += 1 - continue - } - sizeBytes += child.sizeBytes - skippedEntryCount += child.skippedEntryCount - } - - return { - name, - path: entryPath, - kind: 'directory', - sizeBytes, - skippedEntryCount, - children: childStats.filter((child): child is ScanStats => child !== null) - } + return stats.isDirectory() + ? { kind: 'directory', sizeBytes: stats.size } + : { kind: 'file', sizeBytes: stats.size } + }, + readDirectory: (path) => opendir(path), + checkCancelled: () => throwIfAborted(signal), + createCancellationError: () => new WorkspaceSpaceScanCancelledError(), + isCancellationError: (error) => error instanceof WorkspaceSpaceScanCancelledError + }) } async function scanRemoteEntry( entryPath: string, name: string, provider: IFilesystemProvider, - limit: AsyncLimiter, - signal?: AbortSignal, - knownSymlink = false + signal?: AbortSignal ): Promise { - throwIfAborted(signal) - if (knownSymlink) { - return { - name, - path: entryPath, - kind: 'symlink', - sizeBytes: 0, - skippedEntryCount: 0 - } - } - - const stats = await limit(() => provider.stat(entryPath)) - throwIfAborted(signal) - if (stats.type === 'symlink') { - return { - name, - path: entryPath, - kind: 'symlink', - sizeBytes: stats.size, - skippedEntryCount: 0 - } - } - - if (stats.type !== 'directory') { - return { - name, - path: entryPath, - kind: 'file', - sizeBytes: stats.size, - skippedEntryCount: 0 - } - } - - let entries - try { - entries = await limit(() => provider.readDir(entryPath)) - throwIfAborted(signal) - } catch (error) { - if (error instanceof WorkspaceSpaceScanCancelledError) { - throw error - } - return { - name, - path: entryPath, - kind: 'directory', - sizeBytes: stats.size, - skippedEntryCount: 1 - } - } - - const childStats = await Promise.all( - entries.map(async (entry): Promise => { - try { - return await scanRemoteEntry( - joinFilesystemPath(entryPath, entry.name), - entry.name, - provider, - limit, - signal, - entry.isSymlink - ) - } catch (error) { - if (error instanceof WorkspaceSpaceScanCancelledError) { - throw error - } - return null + return scanWorkspaceSpaceEntryTree({ + rootPath: entryPath, + rootName: name, + concurrency: REMOTE_FS_CONCURRENCY, + signal, + entryName: (entry) => entry.name, + joinPath: joinFilesystemPath, + classifyEntry: async (path, sourceEntry) => { + if (sourceEntry?.isSymlink) { + return { kind: 'symlink', sizeBytes: 0 } } - }) - ) - - let sizeBytes = stats.size - let skippedEntryCount = 0 - for (const child of childStats) { - if (!child) { - skippedEntryCount += 1 - continue - } - sizeBytes += child.sizeBytes - skippedEntryCount += child.skippedEntryCount - } - - return { - name, - path: entryPath, - kind: 'directory', - sizeBytes, - skippedEntryCount, - children: childStats.filter((child): child is ScanStats => child !== null) - } + const stats = await provider.stat(path) + throwIfAborted(signal) + if (stats.type === 'symlink') { + return { kind: 'symlink', sizeBytes: stats.size } + } + return stats.type === 'directory' + ? { kind: 'directory', sizeBytes: stats.size } + : { kind: 'file', sizeBytes: stats.size } + }, + readDirectory: (path) => provider.readDir(path), + checkCancelled: () => throwIfAborted(signal), + createCancellationError: () => new WorkspaceSpaceScanCancelledError(), + isCancellationError: (error) => error instanceof WorkspaceSpaceScanCancelledError + }) } async function scanLocalTopLevelEntry( entryPath: string, name: string, duSizes: Map, - limit: AsyncLimiter, signal?: AbortSignal ): Promise { throwIfAborted(signal) - const stats = await limit(() => lstat(entryPath)) + const stats = await lstat(entryPath) throwIfAborted(signal) if (stats.isSymbolicLink()) { @@ -585,13 +401,7 @@ async function scanLocalWorktreeWithDu( throwIfAborted(signal) const rootStats = await lstat(worktree.path) if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { - const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) - const root = await scanLocalEntry( - worktree.path, - basenameFilesystemPath(worktree.path), - limit, - signal - ) + const root = await scanLocalEntry(worktree.path, basenameFilesystemPath(worktree.path), signal) const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) return { ...createBaseWorktreeRow(repo, worktree, scannedAt), @@ -605,19 +415,27 @@ async function scanLocalWorktreeWithDu( } const [entries, duSizes] = await Promise.all([ - readdir(worktree.path, { withFileTypes: true }), + opendir(worktree.path).then((directory) => + collectWorkspaceSpaceDirectoryEntries( + directory, + worktree.path, + (entry) => entry.name, + createWorkspaceSpaceScanBudget(), + () => throwIfAborted(signal) + ) + ), readLocalDuDepthOne(worktree.path, signal) ]) throwIfAborted(signal) - const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) - const childStats = await Promise.all( - entries.map(async (entry): Promise => { + const childStats = await mapWithConcurrency( + entries, + LOCAL_FS_CONCURRENCY, + async (entry): Promise => { try { return await scanLocalTopLevelEntry( joinFilesystemPath(worktree.path, entry.name), entry.name, duSizes, - limit, signal ) } catch (error) { @@ -626,7 +444,7 @@ async function scanLocalWorktreeWithDu( } return null } - }) + } ) const children = childStats.filter((child): child is ScanStats => child !== null) const skippedEntryCount = childStats.length - children.length @@ -653,13 +471,7 @@ async function scanLocalWorktreeWithNode( signal?: AbortSignal ): Promise { try { - const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) - const root = await scanLocalEntry( - worktree.path, - basenameFilesystemPath(worktree.path), - limit, - signal - ) + const root = await scanLocalEntry(worktree.path, basenameFilesystemPath(worktree.path), signal) const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) return { ...createBaseWorktreeRow(repo, worktree, scannedAt), @@ -702,6 +514,16 @@ async function scanLocalWorktree( if (error instanceof WorkspaceSpaceScanCancelledError) { throw error } + if (error instanceof WorkspaceSpaceScanCapacityError) { + const classified = classifyError(error) + return createUnavailableWorktreeRow( + repo, + worktree, + scannedAt, + classified.status, + classified.message + ) + } // Fall through to the portable scanner so unsupported du variants or // permission edge cases still produce partial rows instead of failing. } @@ -734,12 +556,10 @@ async function scanRemoteWorktree( } } - const limit = createAsyncLimiter(REMOTE_FS_CONCURRENCY, signal) const root = await scanRemoteEntry( worktree.path, basenameFilesystemPath(worktree.path), provider, - limit, signal ) const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) @@ -764,7 +584,6 @@ async function scanRemoteWorktree( } async function listWorktreesForSpaceScan( - store: Store, repo: Repo, signal?: AbortSignal ): Promise { @@ -786,10 +605,7 @@ async function listWorktreesForSpaceScan( throwIfAborted(signal) return { ok: true, worktrees } } - const worktrees = await listRepoWorktrees(repo, { - ...getLocalProjectWorktreeGitOptions(store, repo), - signal - }) + const worktrees = await listRepoWorktrees(repo) throwIfAborted(signal) return { ok: true, worktrees } } catch (error) { @@ -831,7 +647,7 @@ async function scanRepo( }, options.onProgress ) - const listed = await listWorktreesForSpaceScan(store, repo, options.signal) + const listed = await listWorktreesForSpaceScan(repo, options.signal) if (!listed.ok) { reportProgress( progress, @@ -867,7 +683,7 @@ async function scanRepo( options.onProgress ) const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined - const rows = await mapLimit(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + const rows = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { throwIfAborted(options.signal) reportProgress( progress, @@ -942,7 +758,7 @@ export async function analyzeWorkspaceSpace( currentWorktreeDisplayName: null } options.onProgress?.({ ...progress }) - const repoResults = await mapLimit(reposToScan, 2, (repo) => + const repoResults = await mapWithConcurrency(reposToScan, 2, (repo) => scanRepo(repo, scannedAt, store, progress, options) ) throwIfAborted(options.signal) diff --git a/src/main/worktree-orphan-gitdir-proof.ts b/src/main/worktree-orphan-gitdir-proof.ts index afa73041777..503e6870b88 100644 --- a/src/main/worktree-orphan-gitdir-proof.ts +++ b/src/main/worktree-orphan-gitdir-proof.ts @@ -1,5 +1,7 @@ import { type posix, win32 } from 'node:path' +export const MAX_WORKTREE_GIT_POINTER_BYTES = 64 * 1024 + type PathOps = typeof posix export type StatPath = (path: string) => Promise export type ReadPath = (path: string) => Promise @@ -41,13 +43,15 @@ function isGitFileStat(stat: unknown): boolean { function readFileResultToText(result: unknown): string | null { if (typeof result === 'string') { - return result + return Buffer.byteLength(result, 'utf8') <= MAX_WORKTREE_GIT_POINTER_BYTES ? result : null } if (Buffer.isBuffer(result)) { - return result.toString('utf8') + return result.byteLength <= MAX_WORKTREE_GIT_POINTER_BYTES ? result.toString('utf8') : null } if (result instanceof Uint8Array) { - return Buffer.from(result).toString('utf8') + return result.byteLength <= MAX_WORKTREE_GIT_POINTER_BYTES + ? Buffer.from(result).toString('utf8') + : null } if (!result || typeof result !== 'object') { return null @@ -56,7 +60,9 @@ function readFileResultToText(result: unknown): string | null { if (remoteRead.isBinary === true || typeof remoteRead.content !== 'string') { return null } - return remoteRead.content + return Buffer.byteLength(remoteRead.content, 'utf8') <= MAX_WORKTREE_GIT_POINTER_BYTES + ? remoteRead.content + : null } function resolveGitdirPath(gitdirPath: string, basePath: string, pathOps: PathOps): string { diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index 5a64b8c90e5..e1d7e97ae79 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -190,6 +190,22 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => { ).resolves.toBe(false) }) + it('rejects oversized git pointer metadata from filesystem providers', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repo', + makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']), + makeReadPath([ + [ + '/workspaces/orphan/.git', + `gitdir: /repo/.git/worktrees/orphan\n${'x'.repeat(64 * 1024)}` + ] + ]) + ) + ).resolves.toBe(false) + }) + it('rejects a copied .git file when the admin entry points at another candidate path', async () => { await expect( canSafelyRemoveOrphanedWorktreeDirectory( diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index a589bce9e10..3ce6d5b0142 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -1,4 +1,4 @@ -import { lstat, readFile } from 'node:fs/promises' +import { lstat } from 'node:fs/promises' import { homedir } from 'node:os' import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' @@ -6,9 +6,11 @@ import type { GitWorktreeInfo, Repo, WorktreeMeta } from '../shared/types' import { areWorktreePathsEqual } from './ipc/worktree-logic' import { gitFileProvesOrphanedWorktreeDirectory, + MAX_WORKTREE_GIT_POINTER_BYTES, type ReadPath, type StatPath } from './worktree-orphan-gitdir-proof' +import { readNodeFileWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' type PathOps = typeof posix @@ -151,7 +153,8 @@ export async function canSafelyRemoveOrphanedWorktreeDirectory( worktreePath: string, repoPath: string, statPath: StatPath = lstat, - readPath: ReadPath = (path) => readFile(path, 'utf8') + readPath: ReadPath = async (path) => + (await readNodeFileWithinLimit(path, MAX_WORKTREE_GIT_POINTER_BYTES)).buffer.toString('utf8') ): Promise { if (isDangerousWorktreeRemovalPath(worktreePath, repoPath)) { return false diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b2d78e851a3..90876f54f87 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -33,6 +33,7 @@ import type { import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { SshMutationExpectation } from '../shared/ssh-types' +import type { RasterImageDimensions } from '../shared/raster-image-dimensions' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -354,6 +355,7 @@ import type { ReactErrorBoundaryReportArgs, ReactErrorBoundaryReportResult } from '../shared/crash-reporting' +import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' export type { ShellOpenExternalEditorRequest, @@ -1374,9 +1376,11 @@ export type PreloadApi = { publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise - inspectProcess: ( - id: string - ) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> + inspectProcess: (id: string) => Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + unavailable?: true + }> confirmForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> @@ -1503,6 +1507,8 @@ export type PreloadApi = { copyLatestDiagnostics: ( args?: CrashReportCopyDiagnosticsArgs ) => Promise<{ ok: true } | { ok: false; error: string }> + /** Exact V8/Blink heap sizes; null when the runtime withholds them. */ + readHeapStatistics: () => RendererHeapStatistics | null } export: ExportApi gh: { @@ -2485,6 +2491,7 @@ export type PreloadApi = { isBinary: boolean isImage?: boolean mimeType?: string + imageDimensions?: RasterImageDimensions fileIdentity?: string }> readLocalLogTail: (args: LocalLogTailReadArgs) => Promise @@ -2744,6 +2751,8 @@ export type PreloadApi = { }) => Promise<{ success: boolean; error?: string }> generateCommitMessage: (args: { worktreePath: string + /** Raw (unstripped) worktree meta key; validated against worktreePath in main. */ + worktreeId?: string repoId?: string connectionId?: string sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams @@ -2772,6 +2781,8 @@ export type PreloadApi = { }) => Promise generatePullRequestFields: (args: { worktreePath: string + /** Raw (unstripped) worktree meta key; validated against worktreePath in main. */ + worktreeId?: string repoId?: string base: string title: string diff --git a/src/preload/emulator-stream-frame-listeners.test.ts b/src/preload/emulator-stream-frame-listeners.test.ts new file mode 100644 index 00000000000..ac2f6c1b72f --- /dev/null +++ b/src/preload/emulator-stream-frame-listeners.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { + subscribeFrameStreamFrames, + subscribeVideoStreamFrames +} from './emulator-stream-frame-listeners' + +type Listener = (event: Electron.IpcRendererEvent, data: never) => void + +function makeIpcRenderer() { + const listeners = new Map() + return { + ipcRenderer: { + on: vi.fn((channel: string, listener: Listener) => { + listeners.set(channel, listener) + }), + removeListener: vi.fn((channel: string, listener: Listener) => { + if (listeners.get(channel) === listener) { + listeners.delete(channel) + } + }), + send: vi.fn() + }, + listeners + } +} + +describe('emulator stream frame listeners', () => { + it('acknowledges an MJPEG frame after the renderer callback', () => { + const { ipcRenderer, listeners } = makeIpcRenderer() + const callback = vi.fn() + const unsubscribe = subscribeFrameStreamFrames(ipcRenderer as never, callback) + const bytes = new ArrayBuffer(2) + + listeners.get('emulator:frameStreamFrame')!( + {} as Electron.IpcRendererEvent, + { + streamId: 'frame-stream', + deliveryId: 7, + bytes + } as never + ) + + expect(callback).toHaveBeenCalledWith({ streamId: 'frame-stream', bytes }) + expect(ipcRenderer.send).toHaveBeenCalledWith('emulator:frameStreamFrameAck', { + streamId: 'frame-stream', + deliveryId: 7 + }) + unsubscribe() + expect(listeners.has('emulator:frameStreamFrame')).toBe(false) + }) + + it('acknowledges an H.264 frame even when the renderer callback throws', () => { + const { ipcRenderer, listeners } = makeIpcRenderer() + const callback = vi.fn(() => { + throw new Error('decode failed') + }) + subscribeVideoStreamFrames(ipcRenderer as never, callback) + + expect(() => + listeners.get('emulator:videoStreamFrame')!( + {} as Electron.IpcRendererEvent, + { + streamId: 'video-stream', + deliveryToken: 'delivery-token', + deliveryId: 9, + deviceId: 'emulator-5554', + config: false, + keyFrame: true, + bytes: new ArrayBuffer(1) + } as never + ) + ).toThrow('decode failed') + expect(ipcRenderer.send).toHaveBeenCalledWith('emulator:videoStreamFrameAck', { + streamId: 'video-stream', + deliveryToken: 'delivery-token', + deliveryId: 9 + }) + }) +}) diff --git a/src/preload/emulator-stream-frame-listeners.ts b/src/preload/emulator-stream-frame-listeners.ts new file mode 100644 index 00000000000..4d26d42ef20 --- /dev/null +++ b/src/preload/emulator-stream-frame-listeners.ts @@ -0,0 +1,60 @@ +type FrameStreamData = { + streamId: string + deliveryId: number + bytes: ArrayBuffer +} + +type VideoStreamData = { + streamId: string + deliveryToken: string + deliveryId: number + deviceId: string + config: boolean + keyFrame: boolean + bytes: ArrayBuffer +} + +type EmulatorStreamIpcRenderer = Pick + +export function subscribeFrameStreamFrames( + ipcRenderer: EmulatorStreamIpcRenderer, + callback: (data: Omit) => void +): () => void { + const listener = (_event: Electron.IpcRendererEvent, data: FrameStreamData): void => { + try { + callback({ streamId: data.streamId, bytes: data.bytes }) + } finally { + ipcRenderer.send('emulator:frameStreamFrameAck', { + streamId: data.streamId, + deliveryId: data.deliveryId + }) + } + } + ipcRenderer.on('emulator:frameStreamFrame', listener) + return () => ipcRenderer.removeListener('emulator:frameStreamFrame', listener) +} + +export function subscribeVideoStreamFrames( + ipcRenderer: EmulatorStreamIpcRenderer, + callback: (data: Omit) => void +): () => void { + const listener = (_event: Electron.IpcRendererEvent, data: VideoStreamData): void => { + try { + callback({ + streamId: data.streamId, + deviceId: data.deviceId, + config: data.config, + keyFrame: data.keyFrame, + bytes: data.bytes + }) + } finally { + ipcRenderer.send('emulator:videoStreamFrameAck', { + streamId: data.streamId, + deliveryToken: data.deliveryToken, + deliveryId: data.deliveryId + }) + } + } + ipcRenderer.on('emulator:videoStreamFrame', listener) + return () => ipcRenderer.removeListener('emulator:videoStreamFrame', listener) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 009924197aa..8736546de83 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,6 +3,10 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' +import { + subscribeFrameStreamFrames, + subscribeVideoStreamFrames +} from './emulator-stream-frame-listeners' import type { AppIdentity } from '../shared/app-identity' import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' import type { @@ -239,6 +243,8 @@ import type { ReactErrorBoundaryReportArgs, ReactErrorBoundaryReportResult } from '../shared/crash-reporting' +import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' +import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' import type { PreloadApi } from './api-types' import { createUpdaterQuitAbortRelay, @@ -984,8 +990,11 @@ const api = { ipcRenderer.invoke('pty:getForegroundProcess', { id }), inspectProcess: ( id: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> => - ipcRenderer.invoke('pty:inspectProcess', { id }), + ): Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + unavailable?: true + }> => ipcRenderer.invoke('pty:inspectProcess', { id }), confirmForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), @@ -1151,7 +1160,8 @@ const api = { submit: (args: CrashReportSubmitArgs): Promise => ipcRenderer.invoke('crashReports:submit', args), copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => - ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args) + ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), + readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics() }, export: { @@ -2592,14 +2602,7 @@ const api = { ipcRenderer.invoke('emulator:frameStreamStop', args), onFrameStreamFrame: ( callback: (data: { streamId: string; bytes: ArrayBuffer }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { streamId: string; bytes: ArrayBuffer } - ) => callback(data) - ipcRenderer.on('emulator:frameStreamFrame', listener) - return () => ipcRenderer.removeListener('emulator:frameStreamFrame', listener) - }, + ): (() => void) => subscribeFrameStreamFrames(ipcRenderer, callback), onFrameStreamError: ( callback: (data: { streamId: string; message: string }) => void ): (() => void) => { @@ -2642,20 +2645,7 @@ const api = { keyFrame: boolean bytes: ArrayBuffer }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - streamId: string - deviceId: string - config: boolean - keyFrame: boolean - bytes: ArrayBuffer - } - ) => callback(data) - ipcRenderer.on('emulator:videoStreamFrame', listener) - return () => ipcRenderer.removeListener('emulator:videoStreamFrame', listener) - }, + ): (() => void) => subscribeVideoStreamFrames(ipcRenderer, callback), onPaneFocus: (callback: (data: { worktreeId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => callback(data) @@ -3137,6 +3127,7 @@ const api = { }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), generateCommitMessage: (args: { worktreePath: string + worktreeId?: string repoId?: string connectionId?: string sourceControlAiResolvedParams?: unknown @@ -3154,6 +3145,7 @@ const api = { }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), generatePullRequestFields: (args: { worktreePath: string + worktreeId?: string repoId?: string base: string title: string diff --git a/src/preload/renderer-heap-statistics-reader.test.ts b/src/preload/renderer-heap-statistics-reader.test.ts new file mode 100644 index 00000000000..892a9e865b7 --- /dev/null +++ b/src/preload/renderer-heap-statistics-reader.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' + +const heapStatistics = { + totalHeapSize: 2048, + totalHeapSizeExecutable: 0, + totalPhysicalSize: 2048, + totalAvailableSize: 4_000_000, + usedHeapSize: 1536, + heapSizeLimit: 4_292_608, + mallocedMemory: 64, + peakMallocedMemory: 96, + doesZapGarbage: false +} + +const source = (overrides: { + heap?: () => Electron.HeapStatistics + blink?: () => Electron.BlinkMemoryInfo +}): Parameters[0] => + ({ + getHeapStatistics: overrides.heap ?? ((): Electron.HeapStatistics => heapStatistics), + getBlinkMemoryInfo: + overrides.blink ?? ((): Electron.BlinkMemoryInfo => ({ allocated: 1227, total: 1280 })) + }) as Parameters[0] + +describe('readRendererHeapStatistics', () => { + it('reports V8 sizes in the kilobytes Electron returns', () => { + expect(readRendererHeapStatistics(source({}))).toEqual({ + usedHeapKB: 1536, + totalHeapKB: 2048, + heapLimitKB: 4_292_608, + mallocedKB: 64, + blinkAllocatedKB: 1227 + }) + }) + + it('keeps the exact V8 read when only the Blink metric throws', () => { + // Why: Blink's number is supplementary. Discarding the V8 read over its + // absence would send callers back to the quantized `performance.memory` + // this reader exists to replace — silently defeating the whole feature. + const result = readRendererHeapStatistics( + source({ + blink: () => { + throw new Error('getBlinkMemoryInfo unavailable') + } + }) + ) + + expect(result).toEqual({ + usedHeapKB: 1536, + totalHeapKB: 2048, + heapLimitKB: 4_292_608, + mallocedKB: 64, + blinkAllocatedKB: undefined + }) + }) + + it('returns null only when the primary V8 read fails', () => { + const blink = vi.fn() + + expect( + readRendererHeapStatistics( + source({ + heap: () => { + throw new Error('getHeapStatistics unavailable') + }, + blink: blink as never + }) + ) + ).toBeNull() + // Why: no reason to pay for the auxiliary call once the result is unusable. + expect(blink).not.toHaveBeenCalled() + }) +}) diff --git a/src/preload/renderer-heap-statistics-reader.ts b/src/preload/renderer-heap-statistics-reader.ts new file mode 100644 index 00000000000..4d1abecd848 --- /dev/null +++ b/src/preload/renderer-heap-statistics-reader.ts @@ -0,0 +1,37 @@ +import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' + +type HeapStatisticsSource = Pick + +/** + * Reads exact V8 heap sizes, which `performance.memory` cannot express: Blink + * quantizes that API and caches it ~20 minutes, so heap growth is invisible to + * it. Available in a sandboxed, context-isolated preload. + */ +export function readRendererHeapStatistics( + source: HeapStatisticsSource = process +): RendererHeapStatistics | null { + let heap: Electron.HeapStatistics + try { + heap = source.getHeapStatistics() + } catch { + // Why: diagnostics must never break the renderer if Electron drops an API. + return null + } + + let blinkAllocatedKB: number | undefined + try { + // Why a separate try: Blink's number is supplementary. Losing it must not + // discard the exact V8 read and send callers back to the quantized metric. + blinkAllocatedKB = source.getBlinkMemoryInfo().allocated + } catch { + blinkAllocatedKB = undefined + } + + return { + usedHeapKB: heap.usedHeapSize, + totalHeapKB: heap.totalHeapSize, + heapLimitKB: heap.heapSizeLimit, + mallocedKB: heap.mallocedMemory, + blinkAllocatedKB + } +} diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index c884c499dac..46a2c06b4aa 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -2,6 +2,7 @@ import { execFile, spawn } from 'node:child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as ChildProcess from 'node:child_process' import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness' +import { MAX_CONCURRENT_AGENT_EXECS } from './agent-exec-handler' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard' vi.mock('child_process', async (importOriginal) => { @@ -16,12 +17,19 @@ vi.mock('child_process', async (importOriginal) => { const spawnMock = vi.mocked(spawn) const execFileMock = vi.mocked(execFile) -type AgentExecResult = { exitCode: number | null; timedOut: boolean } +type AgentExecResult = { stdout: string; exitCode: number | null; timedOut: boolean } describe('AgentExecHandler', () => { beforeEach(() => { spawnMock.mockReset() execFileMock.mockReset() + // Why: ambient indexed git config (e.g. from a credential-guarded shell) + // shifts the guard's appended entries and breaks exact env expectations. + for (const key of Object.keys(process.env)) { + if (key === 'GIT_CONFIG_COUNT' || /^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(key)) { + delete process.env[key] + } + } }) it('executes a non-interactive command with captured output and stdin', async () => { @@ -64,6 +72,68 @@ describe('AgentExecHandler', () => { expect(child.stdin.end).toHaveBeenCalledWith('PROMPT') }) + it('preserves output delivered as 100,000 one-byte fragments', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child as never) + const handlers = createHandlers() + const pending = handlers.get('agent.execNonInteractive')!( + { binary: 'agent', cwd: '/repo', timeoutMs: 5_000 }, + requestContext() + ) as Promise + + for (let index = 0; index < 100_000; index += 1) { + child.stdout.emit('data', Buffer.from(index % 2 === 0 ? 'a' : 'b')) + } + child.emit('close', 0) + + const result = await pending + expect(result.stdout).toHaveLength(100_000) + expect(result.stdout.slice(0, 4)).toBe('abab') + expect(result.stdout.slice(-4)).toBe('abab') + }) + + it('caps physical agent children until close confirms release', async () => { + const children = Array.from({ length: MAX_CONCURRENT_AGENT_EXECS + 1 }, (_, index) => { + const child = createFakeChild() + child.pid += index + return child + }) + let spawnIndex = 0 + spawnMock.mockImplementation(() => children[spawnIndex++] as never) + const handlers = createHandlers() + const active = children + .slice(0, MAX_CONCURRENT_AGENT_EXECS) + .map((_, index) => + handlers.get('agent.execNonInteractive')!( + { binary: 'agent', cwd: `/repo-${index}`, timeoutMs: 5_000 }, + requestContext() + ) + ) + + await expect( + handlers.get('agent.execNonInteractive')!( + { binary: 'agent', cwd: '/overflow', timeoutMs: 5_000 }, + requestContext() + ) + ).resolves.toMatchObject({ spawnError: 'Remote agent execution capacity reached' }) + expect(spawnMock).toHaveBeenCalledTimes(MAX_CONCURRENT_AGENT_EXECS) + + children[0]!.emit('close', 0) + await active[0] + const retry = handlers.get('agent.execNonInteractive')!( + { binary: 'agent', cwd: '/retry', timeoutMs: 5_000 }, + requestContext() + ) + expect(spawnMock).toHaveBeenCalledTimes(MAX_CONCURRENT_AGENT_EXECS + 1) + + for (const child of children.slice(1)) { + child.emit('close', 0) + } + await expect(Promise.all([...active.slice(1), retry])).resolves.toHaveLength( + MAX_CONCURRENT_AGENT_EXECS + ) + }) + it('merges caller-supplied provider environment into the spawned command environment', async () => { const child = createFakeChild() spawnMock.mockReturnValue(child as never) @@ -450,6 +520,8 @@ describe('AgentExecHandler', () => { expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(1) + child.emit('close', null) expect(child.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index a82791dcf45..9adb3d0b6c4 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -3,12 +3,14 @@ import { existsSync } from 'node:fs' import { delimiter, join } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard' +import { GrowingByteBuffer } from '../shared/growing-byte-buffer' import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' import { terminateRelaySubprocessTree } from './subprocess-tree-termination' const DEFAULT_TIMEOUT_MS = 60_000 const MAX_TIMEOUT_MS = 5 * 60 * 1000 const MAX_OUTPUT_BYTES = 4 * 1024 * 1024 +export const MAX_CONCURRENT_AGENT_EXECS = 8 const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' function getCmdExePath(): string { @@ -111,6 +113,7 @@ export class AgentExecHandler { // Why: commit-message and PR-field generation can run together for one cwd; // operation lanes let cancel target only the user-visible job that stopped. private inFlightByLane = new Map() + private activeChildren = new Set() private laneKey(cwd: string, operation: unknown): string { return laneKeyFor(cwd, operation) @@ -159,6 +162,16 @@ export class AgentExecHandler { platform: process.platform }) + if (this.activeChildren.size >= MAX_CONCURRENT_AGENT_EXECS) { + return { + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + spawnError: 'Remote agent execution capacity reached' + } + } + return new Promise((resolve) => { let child try { @@ -179,11 +192,13 @@ export class AgentExecHandler { }) return } + this.activeChildren.add(child) + child.once('close', () => { + this.activeChildren.delete(child) + }) - let stdout = '' - let stderr = '' - let stdoutBytes = 0 - let stderrBytes = 0 + const stdout = new GrowingByteBuffer() + const stderr = new GrowingByteBuffer() let timedOut = false let canceled = false let settled = false @@ -206,6 +221,8 @@ export class AgentExecHandler { if (laneKey && entry && this.inFlightByLane.get(laneKey) === entry) { this.inFlightByLane.delete(laneKey) } + stdout.clear() + stderr.clear() resolve(result) } const cancelCurrent = (): void => { @@ -231,36 +248,46 @@ export class AgentExecHandler { // also Windows wraps `.cmd` shims in cmd.exe, so the immediate child // is not the real node.exe process. terminateRelaySubprocessTree(child) - finish({ stdout, stderr, exitCode: null, timedOut, canceled }) + finish({ + stdout: stdout.toString(), + stderr: stderr.toString(), + exitCode: null, + timedOut, + canceled + }) }, timeoutMs) const onStdoutData = (chunk: Buffer): void => { - stdoutBytes += chunk.byteLength - if (stdoutBytes > MAX_OUTPUT_BYTES) { + if (stdout.byteLength + chunk.byteLength > MAX_OUTPUT_BYTES) { terminateRelaySubprocessTree(child) return } - stdout += chunk.toString('utf-8') + stdout.append(chunk) } const onStderrData = (chunk: Buffer): void => { - stderrBytes += chunk.byteLength - if (stderrBytes > MAX_OUTPUT_BYTES) { + if (stderr.byteLength + chunk.byteLength > MAX_OUTPUT_BYTES) { terminateRelaySubprocessTree(child) return } - stderr += chunk.toString('utf-8') + stderr.append(chunk) } const onError = (error: Error): void => { finish({ - stdout, - stderr, + stdout: stdout.toString(), + stderr: stderr.toString(), exitCode: null, timedOut, spawnError: error.message }) } const onClose = (code: number | null): void => { - finish({ stdout, stderr, exitCode: code, timedOut, canceled }) + finish({ + stdout: stdout.toString(), + stderr: stderr.toString(), + exitCode: code, + timedOut, + canceled + }) } child.stdout?.on('data', onStdoutData) child.stderr?.on('data', onStderrData) diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 3d73506c7bf..68f6c7c1e4c 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' -import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' +import { + endpointDirForRelaySocket, + MAX_RELAY_AGENT_HOOK_STATUS_CACHE_PANES, + RelayAgentHookServer +} from './agent-hook-server' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' import * as agentHookListener from '../shared/agent-hook-listener' @@ -391,10 +395,8 @@ describe('RelayAgentHookServer', () => { } }) - it('caps the replay cache at 256 panes, evicting the least-recently-updated', async () => { - // Mirrors the server's private MAX_CACHED_PANES. The WSL relay never gets a - // per-pane teardown signal, so the cache is recency-capped instead. - const CAP = 256 + it('caps the replay cache, evicting the least-recently-updated', async () => { + const CAP = MAX_RELAY_AGENT_HOOK_STATUS_CACHE_PANES const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) await server.start() diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 40775b5bd6a..69bacaf3544 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -29,6 +29,7 @@ import { type AgentHookRelayEnvelope, type AgentHookSource } from '../shared/agent-hook-relay' +import { upsertBoundedAgentHookStatus } from '../shared/agent-hook-status-cache' export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void @@ -40,9 +41,8 @@ const ASSISTANT_MESSAGE_RETRY_MS = 50 // Why: cap env/version at 64 chars so a misbehaving agent CLI can't grow the meta cache unboundedly; canonical values are short. const MAX_HOOK_META_LEN = 64 - -// Why: WSL relay has no per-pane teardown (PTYs live on the Windows host), so the replay cache would grow forever without a recency cap. -const MAX_CACHED_PANES = 256 +// Why: preserve the relay's existing replay-memory ceiling while main uses the renderer-aligned 500-pane cap. +export const MAX_RELAY_AGENT_HOOK_STATUS_CACHE_PANES = 256 function defaultEndpointDir(): string { return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR) @@ -326,17 +326,14 @@ export class RelayAgentHookServer { if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.clearAssistantMessageRetry(event.paneKey) } - // Why: delete-then-set makes Map insertion order = recency, so the cap below evicts the longest-idle pane. - this.state.lastStatusByPaneKey.delete(event.paneKey) - this.state.lastStatusByPaneKey.set(event.paneKey, event) + const evicted = upsertBoundedAgentHookStatus(this.state, event, { + maxPanes: MAX_RELAY_AGENT_HOOK_STATUS_CACHE_PANES + }) this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) - while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) { - const oldest = this.state.lastStatusByPaneKey.keys().next().value - if (oldest === undefined) { - break - } - this.clearPaneState(oldest) + for (const { paneKey } of evicted) { + this.clearAssistantMessageRetry(paneKey) + this.lastEnvelopeMetaByPaneKey.delete(paneKey) } this.forwardEvent(event, source, env, version) } diff --git a/src/relay/client-request-aborts.test.ts b/src/relay/client-request-aborts.test.ts new file mode 100644 index 00000000000..3b4cdac75fb --- /dev/null +++ b/src/relay/client-request-aborts.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { + ClientRequestAborts, + MAX_ACTIVE_RELAY_REQUEST_BYTES, + MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT, + MAX_ACTIVE_RELAY_REQUESTS, + MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT +} from './client-request-aborts' + +describe('ClientRequestAborts', () => { + it('accepts the per-client request boundary, rejects overflow, and recovers', () => { + const requests = new ClientRequestAborts() + const registrations = Array.from( + { length: MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT }, + (_, requestId) => requests.create(1, requestId) + ) + + expect(() => requests.create(1, MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT)).toThrow( + `Relay client active request limit of ${MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT} reached` + ) + + requests.delete(registrations[0].key) + expect(() => requests.create(1, MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT)).not.toThrow() + }) + + it('accepts the aggregate request boundary without evicting active clients', () => { + const requests = new ClientRequestAborts() + for (let index = 0; index < MAX_ACTIVE_RELAY_REQUESTS; index += 1) { + const clientId = Math.floor(index / MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT) + 1 + requests.create(clientId, index) + } + + expect(() => requests.create(99, MAX_ACTIVE_RELAY_REQUESTS)).toThrow( + `Relay active request limit of ${MAX_ACTIVE_RELAY_REQUESTS} reached` + ) + expect(requests.get(1, 0)?.signal.aborted).toBe(false) + + requests.delete({ clientId: 1, requestId: 0 }) + expect(() => requests.create(99, MAX_ACTIVE_RELAY_REQUESTS)).not.toThrow() + }) + + it('bounds retained payload bytes per client and releases the budget on delete', () => { + const requests = new ClientRequestAborts() + const halfBudget = MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT / 2 + const first = requests.create(1, 1, halfBudget) + requests.create(1, 2, halfBudget) + + expect(() => requests.create(1, 3, 1)).toThrow( + `Relay client active request payload limit of ${MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT} bytes exceeded` + ) + + requests.delete(first.key) + expect(() => requests.create(1, 3, 1)).not.toThrow() + }) + + it('bounds aggregate retained payload bytes and recovers after client abort', () => { + const requests = new ClientRequestAborts() + requests.create(1, 1, MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT) + requests.create( + 2, + 2, + MAX_ACTIVE_RELAY_REQUEST_BYTES - MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT + ) + + expect(() => requests.create(3, 3, 1)).toThrow( + `Relay active request payload limit of ${MAX_ACTIVE_RELAY_REQUEST_BYTES} bytes exceeded` + ) + + requests.abortClient(1) + expect(() => requests.create(3, 3, 1)).not.toThrow() + }) + + it('rejects duplicate ids and releases only the requested owners', () => { + const requests = new ClientRequestAborts() + const first = requests.create(1, 7) + const second = requests.create(2, 7) + + expect(() => requests.create(1, 7)).toThrow('Duplicate active relay request id 7') + expect(requests.get(1, 7)).toBe(first.controller) + + requests.abortClient(1) + expect(first.controller.signal.aborted).toBe(true) + expect(second.controller.signal.aborted).toBe(false) + expect(requests.get(1, 7)).toBeUndefined() + expect(requests.get(2, 7)).toBe(second.controller) + + requests.abortAll() + expect(second.controller.signal.aborted).toBe(true) + expect(requests.get(2, 7)).toBeUndefined() + }) +}) diff --git a/src/relay/client-request-aborts.ts b/src/relay/client-request-aborts.ts index 12d44363d0d..b2b984ebf08 100644 --- a/src/relay/client-request-aborts.ts +++ b/src/relay/client-request-aborts.ts @@ -1,40 +1,118 @@ +export const MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT = 256 +export const MAX_ACTIVE_RELAY_REQUESTS = 1024 +// Why: handlers retain parsed params until settlement, so cardinality alone +// cannot bound a burst of near-maximum relay frames. +export const MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT = 32 * 1024 * 1024 +export const MAX_ACTIVE_RELAY_REQUEST_BYTES = 64 * 1024 * 1024 + +type ClientRequestAbortEntry = { + controller: AbortController + retainedBytes: number +} + +export type ClientRequestAbortKey = { + clientId: number + requestId: number +} + +export class RelayRequestAdmissionError extends Error { + constructor(message: string) { + super(message) + this.name = 'RelayRequestAdmissionError' + } +} + export class ClientRequestAborts { - private readonly controllers = new Map() + private readonly entriesByClient = new Map>() + private activeRequestCount = 0 + private activeRequestBytes = 0 + + create( + clientId: number, + requestId: number, + retainedBytes = 0 + ): { key: ClientRequestAbortKey; controller: AbortController } { + const clientEntries = this.entriesByClient.get(clientId) + if (clientEntries?.has(requestId)) { + throw new RelayRequestAdmissionError(`Duplicate active relay request id ${requestId}`) + } + if ((clientEntries?.size ?? 0) >= MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT) { + throw new RelayRequestAdmissionError( + `Relay client active request limit of ${MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT} reached` + ) + } + if (this.activeRequestCount >= MAX_ACTIVE_RELAY_REQUESTS) { + throw new RelayRequestAdmissionError( + `Relay active request limit of ${MAX_ACTIVE_RELAY_REQUESTS} reached` + ) + } + const normalizedBytes = Math.max(0, retainedBytes) + const clientBytes = sumRetainedBytes(clientEntries?.values()) + if (clientBytes + normalizedBytes > MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT) { + throw new RelayRequestAdmissionError( + `Relay client active request payload limit of ${MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT} bytes exceeded` + ) + } + if (this.activeRequestBytes + normalizedBytes > MAX_ACTIVE_RELAY_REQUEST_BYTES) { + throw new RelayRequestAdmissionError( + `Relay active request payload limit of ${MAX_ACTIVE_RELAY_REQUEST_BYTES} bytes exceeded` + ) + } - create(clientId: number, requestId: number): { key: string; controller: AbortController } { - const key = this.key(clientId, requestId) const controller = new AbortController() - this.controllers.set(key, controller) - return { key, controller } + const entries = clientEntries ?? new Map() + entries.set(requestId, { controller, retainedBytes: normalizedBytes }) + this.entriesByClient.set(clientId, entries) + this.activeRequestCount += 1 + this.activeRequestBytes += normalizedBytes + return { key: { clientId, requestId }, controller } } get(clientId: number, requestId: number): AbortController | undefined { - return this.controllers.get(this.key(clientId, requestId)) + return this.entriesByClient.get(clientId)?.get(requestId)?.controller } - delete(key: string): void { - this.controllers.delete(key) + delete(key: ClientRequestAbortKey): void { + const clientEntries = this.entriesByClient.get(key.clientId) + const entry = clientEntries?.get(key.requestId) + if (!clientEntries || !entry) { + return + } + clientEntries.delete(key.requestId) + this.activeRequestCount -= 1 + this.activeRequestBytes -= entry.retainedBytes + if (clientEntries.size === 0) { + this.entriesByClient.delete(key.clientId) + } } abortClient(clientId: number): void { - const prefix = `${clientId}:` - for (const [key, controller] of this.controllers) { - if (!key.startsWith(prefix)) { - continue - } - controller.abort() - this.controllers.delete(key) + const clientEntries = this.entriesByClient.get(clientId) + if (!clientEntries) { + return + } + for (const [requestId, entry] of clientEntries) { + entry.controller.abort() + this.delete({ clientId, requestId }) } } abortAll(): void { - for (const [, controller] of this.controllers) { - controller.abort() + for (const clientEntries of this.entriesByClient.values()) { + for (const entry of clientEntries.values()) { + entry.controller.abort() + } } - this.controllers.clear() - } - - private key(clientId: number, requestId: number): string { - return `${clientId}:${requestId}` + this.entriesByClient.clear() + this.activeRequestCount = 0 + this.activeRequestBytes = 0 } } + +function sumRetainedBytes(entries?: Iterable): number { + let total = 0 + for (const entry of entries ?? []) { + total += entry.retainedBytes + } + return total +} diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 87f5cb69053..66b1f81468b 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { RelayDispatcher } from './dispatcher' +import { + MAX_PENDING_RELAY_REQUESTS, + MAX_RELAY_DISPATCHER_CLIENTS, + RelayDispatcher +} from './dispatcher' +import { MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT } from './client-request-aborts' import { encodeJsonRpcFrame, encodeKeepAliveFrame, MessageType, type JsonRpcRequest, - type JsonRpcNotification + type JsonRpcNotification, + type JsonRpcResponse } from './protocol' function decodeFirstFrame(buf: Buffer): { type: number; id: number; ack: number; payload: Buffer } { @@ -118,6 +124,48 @@ describe('RelayDispatcher', () => { expect(resp.id).toBe(5) }) + it('rejects incoming request overflow without evicting active work', async () => { + const resolutions: ((value: unknown) => void)[] = [] + const handler = vi.fn( + (_params, context) => + new Promise((resolve) => { + resolutions.push(resolve) + context.signal?.addEventListener('abort', () => resolve(null), { once: true }) + }) + ) + dispatcher.onRequest('slow.method', handler) + + for (let id = 1; id <= MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT + 1; id += 1) { + dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id, method: 'slow.method' }, id, 0)) + } + await vi.advanceTimersByTimeAsync(0) + + expect(handler).toHaveBeenCalledTimes(MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT) + const overflowId = MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT + 1 + const overflow = written + .map(decodeFirstFrame) + .filter((frame) => frame.type === MessageType.Regular) + .map((frame) => JSON.parse(frame.payload.toString('utf-8')) as JsonRpcResponse) + .find((response) => response.id === overflowId) + expect(overflow?.error?.message).toBe( + `Relay client active request limit of ${MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT} reached` + ) + expect(resolutions).toHaveLength(MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT) + + resolutions[0]('done') + await vi.advanceTimersByTimeAsync(0) + dispatcher.feed( + encodeJsonRpcFrame( + { jsonrpc: '2.0', id: overflowId + 1, method: 'slow.method' }, + overflowId + 1, + 0 + ) + ) + await vi.advanceTimersByTimeAsync(0) + + expect(handler).toHaveBeenCalledTimes(MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT + 1) + }) + it('sends method-not-found for unknown methods', async () => { const req: JsonRpcRequest = { jsonrpc: '2.0', @@ -201,6 +249,21 @@ describe('RelayDispatcher', () => { expect(socketWritten).toHaveLength(1) }) + it('accepts the exact client cap, rejects aggregate overflow, and recovers after detach', () => { + const attachedIds = Array.from({ length: MAX_RELAY_DISPATCHER_CLIENTS - 1 }, () => + dispatcher.attachClient(() => undefined) + ) + + expect(dispatcher.connectedClientIds()).toHaveLength(MAX_RELAY_DISPATCHER_CLIENTS) + expect(() => dispatcher.attachClient(() => undefined)).toThrow( + `Relay client limit of ${MAX_RELAY_DISPATCHER_CLIENTS} reached` + ) + + dispatcher.detachClient(attachedIds[0]) + expect(() => dispatcher.attachClient(() => undefined)).not.toThrow() + expect(dispatcher.connectedClientIds()).toHaveLength(MAX_RELAY_DISPATCHER_CLIENTS) + }) + it('targets terminal ownership notifications to one attached client', () => { const firstWritten: Buffer[] = [] const secondWritten: Buffer[] = [] @@ -218,6 +281,76 @@ describe('RelayDispatcher', () => { expect(secondWritten).toHaveLength(0) }) + it('reports targeted sink saturation and waits for its drain signal', async () => { + const ownerWritten: Buffer[] = [] + let signalDrain: (() => void) | undefined + const ownerId = dispatcher.attachClient( + (data) => { + ownerWritten.push(Buffer.from(data)) + return false + }, + { + waitWriteDrain: (callback) => { + signalDrain = callback + } + } + ) + const otherWritten: Buffer[] = [] + dispatcher.attachClient((data) => { + otherWritten.push(Buffer.from(data)) + }) + + const result = dispatcher.notifyClientWithBackpressure(ownerId, 'pty.data', { + id: 'pty-1', + data: 'output' + }) + + expect(result.delivered).toBe(true) + expect(result.saturated).toBe(true) + expect(ownerWritten).toHaveLength(1) + expect(written).toHaveLength(0) + expect(otherWritten).toHaveLength(0) + let drained = false + void result.drained.then(() => { + drained = true + }) + await Promise.resolve() + expect(drained).toBe(false) + + signalDrain?.() + await expect(result.drained).resolves.toBeUndefined() + expect(drained).toBe(true) + }) + + it('reports a detached targeted client without writing', async () => { + const ownerId = dispatcher.attachClient(() => true) + dispatcher.detachClient(ownerId) + + const result = dispatcher.notifyClientWithBackpressure(ownerId, 'pty.data', { + id: 'pty-1', + data: 'output' + }) + + expect(result).toMatchObject({ delivered: false, saturated: false }) + await expect(result.drained).resolves.toBeUndefined() + expect(written).toHaveLength(0) + }) + + it('allows a targeted drain waiter to be canceled during owner cleanup', async () => { + const ownerId = dispatcher.attachClient(() => false, { + waitWriteDrain: () => {} + }) + const result = dispatcher.notifyClientWithBackpressure(ownerId, 'pty.data', { + id: 'pty-1', + data: 'output' + }) + + expect(result.saturated).toBe(true) + result.cancelDrain?.() + + await expect(result.drained).resolves.toBeUndefined() + }) + it('forwards relay-originated requests to an owning socket client instead of the caller', async () => { dispatcher.invalidateClient() const ownerWritten: Buffer[] = [] @@ -273,6 +406,48 @@ describe('RelayDispatcher', () => { await expect(pending).resolves.toEqual({ exitCode: 0 }) }) + it('bounds relay-originated pending requests and recovers after a response', async () => { + const pending = Array.from({ length: MAX_PENDING_RELAY_REQUESTS }, (_, index) => { + const request = dispatcher.requestPrimary('orca.cli', { index }) + void request.catch(() => undefined) + return request + }) + + expect(written).toHaveLength(MAX_PENDING_RELAY_REQUESTS) + await expect(dispatcher.requestPrimary('orca.cli', { overflow: true })).rejects.toThrow( + `Relay pending request limit of ${MAX_PENDING_RELAY_REQUESTS} reached` + ) + expect(written).toHaveLength(MAX_PENDING_RELAY_REQUESTS) + + const firstRequest = JSON.parse( + decodeFirstFrame(written[0]).payload.toString('utf-8') + ) as JsonRpcRequest + dispatcher.feed( + encodeJsonRpcFrame({ jsonrpc: '2.0', id: firstRequest.id, result: 'done' }, 1, 0) + ) + await expect(pending[0]).resolves.toBe('done') + + const recovered = dispatcher.requestPrimary('orca.cli', { recovered: true }) + expect(written).toHaveLength(MAX_PENDING_RELAY_REQUESTS + 1) + const recoveredRequest = JSON.parse( + decodeFirstFrame(written.at(-1)!).payload.toString('utf-8') + ) as JsonRpcRequest + dispatcher.feed( + encodeJsonRpcFrame({ jsonrpc: '2.0', id: recoveredRequest.id, result: 'recovered' }, 2, 0) + ) + await expect(recovered).resolves.toBe('recovered') + }) + + it('rejects relay-originated requests when their owning client detaches', async () => { + const ownerId = dispatcher.attachClient(() => undefined) + dispatcher.invalidateClient() + const pending = dispatcher.requestAnyClient('orca.cli') + + dispatcher.detachClient(ownerId) + + await expect(pending).rejects.toThrow('Relay client disconnected') + }) + it('isolates failed socket-client writes from other clients', () => { const goodSocketWritten: Buffer[] = [] const failingClientId = dispatcher.attachClient(() => { diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 1f1445da2f1..4b1ac665121 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -11,7 +11,7 @@ import { type JsonRpcNotification, type JsonRpcResponse } from './protocol' -import { ClientRequestAborts } from './client-request-aborts' +import { ClientRequestAborts, RelayRequestAdmissionError } from './client-request-aborts' export type RequestContext = { clientId: number @@ -32,6 +32,15 @@ export type RelayClientWrite = (data: Buffer) => boolean | void export type RelayClientSinkOptions = { /** One-shot: invoke `cb` when the sink can accept more data (drain) or is permanently dead, so waiters never hang. */ waitWriteDrain?: (cb: () => void) => void + /** Close only this transport when bounded per-client delivery can no longer retain its backlog. */ + disconnect?: () => void +} + +export type RelayNotificationWriteResult = { + delivered: boolean + saturated: boolean + drained: Promise + cancelDrain?: () => void } type RelayClient = { @@ -39,6 +48,7 @@ type RelayClient = { decoder: FrameDecoder write: RelayClientWrite waitWriteDrain?: (cb: () => void) => void + disconnect?: () => void /** Resolvers for bulk sends stalled on sink saturation; flushed so no pump hangs. */ drainWaiters: Set<() => void> /** Serializes bulk-lane sends so only one bulk frame is admitted past the sink high-water mark at a time. */ @@ -50,12 +60,16 @@ type RelayClient = { } type PendingRelayRequest = { + clientId: number resolve: (result: unknown) => void reject: (error: Error) => void timer: ReturnType } const RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS = 30_000 +export const MAX_RELAY_DISPATCHER_CLIENTS = 16 +export const MAX_RELAY_SOCKET_CONNECTIONS = MAX_RELAY_DISPATCHER_CLIENTS - 1 +export const MAX_PENDING_RELAY_REQUESTS = 256 export class RelayDispatcher { private readonly primaryClient: RelayClient @@ -80,8 +94,10 @@ export class RelayDispatcher { // Why: the new client's multiplexer restarts at seq=1, so reset seq/decoder state or acks stall and fire a false connection-dead signal. setWrite(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): void { this.requestAborts.abortClient(this.primaryClient.id) + this.rejectPendingRequestsForClient(this.primaryClient.id, 'Relay client reconnected') this.primaryClient.write = write this.primaryClient.waitWriteDrain = sinkOptions?.waitWriteDrain + this.primaryClient.disconnect = sinkOptions?.disconnect this.primaryClient.closed = false // Why: the old sink is gone; wake stalled bulk senders to re-evaluate against the new one. this.flushDrainWaiters(this.primaryClient) @@ -91,6 +107,7 @@ export class RelayDispatcher { // Why: mark in-flight requests stale on disconnect so a late pty.spawn/fs.watch can't create unowned remote state. invalidateClient(): void { this.requestAborts.abortClient(this.primaryClient.id) + this.rejectPendingRequestsForClient(this.primaryClient.id, 'Relay client disconnected') this.primaryClient.generation++ this.primaryClient.closed = true this.flushDrainWaiters(this.primaryClient) @@ -99,6 +116,12 @@ export class RelayDispatcher { // Why: seq numbers and request ids are per SSH channel, so each attached client needs independent protocol state. attachClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): number { + if (this.disposed) { + throw new Error('Relay dispatcher is disposed') + } + if (this.clients.size >= MAX_RELAY_DISPATCHER_CLIENTS) { + throw new Error(`Relay client limit of ${MAX_RELAY_DISPATCHER_CLIENTS} reached`) + } const client = this.createClient(write, sinkOptions) this.clients.set(client.id, client) return client.id @@ -110,6 +133,7 @@ export class RelayDispatcher { return } this.requestAborts.abortClient(clientId) + this.rejectPendingRequestsForClient(clientId, 'Relay client disconnected') client.generation++ client.closed = true this.flushDrainWaiters(client) @@ -117,6 +141,33 @@ export class RelayDispatcher { this.notifyClientDetached(clientId) } + connectedClientIds(): number[] { + if (this.disposed) { + return [] + } + return Array.from(this.clients.values(), (client) => client) + .filter((client) => !client.closed) + .map((client) => client.id) + } + + evictClient(clientId: number): void { + const client = this.clients.get(clientId) + if (!client || client.closed) { + return + } + const disconnect = client.disconnect + if (client === this.primaryClient) { + this.invalidateClient() + } else { + this.detachClient(clientId) + } + try { + disconnect?.() + } catch { + // Why: disconnect failures cannot restore an already-detached dispatcher. + } + } + feedClient(clientId: number, data: Buffer): void { const client = this.clients.get(clientId) if (!client) { @@ -184,6 +235,35 @@ export class RelayDispatcher { }) } + notifyClientWithBackpressure( + clientId: number, + method: string, + params?: Record + ): RelayNotificationWriteResult { + const client = this.clients.get(clientId) + if (this.disposed || !client || client.closed) { + return { delivered: false, saturated: false, drained: Promise.resolve() } + } + const accepted = this.sendFrame(client, { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + }) + if (client.closed || !this.clients.has(clientId)) { + return { delivered: false, saturated: false, drained: Promise.resolve() } + } + if (accepted !== false) { + return { delivered: true, saturated: false, drained: Promise.resolve() } + } + const drain = this.waitForClientDrainCancelable(client) + return { + delivered: true, + saturated: true, + drained: drain.drained, + cancelDrain: drain.cancel + } + } + /** * Bulk-lane notification: sends are serialized per client and the promise * resolves only after the sink accepted the frame (backpressure), so bulk @@ -233,10 +313,18 @@ export class RelayDispatcher { } private waitForClientDrain(client: RelayClient): Promise { + return this.waitForClientDrainCancelable(client).drained + } + + private waitForClientDrainCancelable(client: RelayClient): { + drained: Promise + cancel: () => void + } { if (this.disposed || client.closed || !client.waitWriteDrain) { - return Promise.resolve() + return { drained: Promise.resolve(), cancel: () => {} } } - return new Promise((resolve) => { + let cancel = (): void => {} + const drained = new Promise((resolve) => { let settled = false const finish = (): void => { if (settled) { @@ -246,6 +334,7 @@ export class RelayDispatcher { client.drainWaiters.delete(finish) resolve() } + cancel = finish client.drainWaiters.add(finish) try { client.waitWriteDrain!(finish) @@ -253,6 +342,7 @@ export class RelayDispatcher { finish() } }) + return { drained, cancel } } private flushDrainWaiters(client: RelayClient): void { @@ -295,6 +385,11 @@ export class RelayDispatcher { if (this.disposed || !client || client.closed) { return Promise.reject(new Error('Relay client is not connected')) } + if (this.pendingRelayRequests.size >= MAX_PENDING_RELAY_REQUESTS) { + return Promise.reject( + new Error(`Relay pending request limit of ${MAX_PENDING_RELAY_REQUESTS} reached`) + ) + } const id = this.nextRequestId++ const msg: JsonRpcRequest = { jsonrpc: '2.0', @@ -308,11 +403,27 @@ export class RelayDispatcher { this.pendingRelayRequests.delete(id) reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`)) }, timeoutMs) - this.pendingRelayRequests.set(id, { resolve, reject, timer }) + this.pendingRelayRequests.set(id, { clientId, resolve, reject, timer }) this.sendFrame(client, msg) + if (client.closed) { + clearTimeout(timer) + this.pendingRelayRequests.delete(id) + reject(new Error('Relay client disconnected')) + } }) } + private rejectPendingRequestsForClient(clientId: number, message: string): void { + for (const [id, pending] of this.pendingRelayRequests) { + if (pending.clientId !== clientId) { + continue + } + clearTimeout(pending.timer) + this.pendingRelayRequests.delete(id) + pending.reject(new Error(message)) + } + } + dispose(): void { if (this.disposed) { return @@ -341,6 +452,7 @@ export class RelayDispatcher { decoder: new FrameDecoder((frame) => this.handleFrame(client, frame)), write, waitWriteDrain: sinkOptions?.waitWriteDrain, + disconnect: sinkOptions?.disconnect, drainWaiters: new Set(), bulkChain: Promise.resolve(), nextOutgoingSeq: 1, @@ -371,7 +483,7 @@ export class RelayDispatcher { if (frame.type === MessageType.Regular) { try { const msg = parseJsonRpcMessage(frame.payload) - this.handleMessage(client, msg) + this.handleMessage(client, msg, frame.payload.byteLength) } catch (err) { process.stderr.write( `[relay] Parse error: ${err instanceof Error ? err.message : String(err)}\n` @@ -382,10 +494,11 @@ export class RelayDispatcher { private handleMessage( client: RelayClient, - msg: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse + msg: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse, + retainedBytes: number ): void { if ('id' in msg && 'method' in msg) { - void this.handleRequest(client, msg as JsonRpcRequest) + void this.handleRequest(client, msg as JsonRpcRequest, retainedBytes) } else if ('id' in msg && ('result' in msg || 'error' in msg)) { this.handleResponse(msg as JsonRpcResponse) } else if ('method' in msg && !('id' in msg)) { @@ -410,7 +523,11 @@ export class RelayDispatcher { pending.resolve(msg.result) } - private async handleRequest(client: RelayClient, req: JsonRpcRequest): Promise { + private async handleRequest( + client: RelayClient, + req: JsonRpcRequest, + retainedBytes: number + ): Promise { const handler = this.requestHandlers.get(req.method) if (!handler) { this.sendResponse(client, req.id, undefined, { @@ -422,10 +539,17 @@ export class RelayDispatcher { // Why: snapshot generation before the await to detect if the client disconnected mid-flight. const gen = client.generation - const { key: abortKey, controller: abortController } = this.requestAborts.create( - client.id, - req.id - ) + let registration: ReturnType + try { + registration = this.requestAborts.create(client.id, req.id, retainedBytes) + } catch (error) { + if (error instanceof RelayRequestAdmissionError) { + this.sendResponse(client, req.id, undefined, { code: -32000, message: error.message }) + return + } + throw error + } + const { key: abortKey, controller: abortController } = registration const context: RequestContext = { clientId: client.id, isStale: () => @@ -518,6 +642,7 @@ export class RelayDispatcher { client.closed = true client.generation++ this.requestAborts.abortClient(client.id) + this.rejectPendingRequestsForClient(client.id, 'Relay client disconnected') this.flushDrainWaiters(client) // Why: frames have no retransmit buffer; detach now so reconnect/PTY-reattach runs instead of waiting the ~20s keepalive timeout. if (client !== this.primaryClient) { diff --git a/src/relay/external-automations-handler.test.ts b/src/relay/external-automations-handler.test.ts index d2a19d79c2d..993131f27ab 100644 --- a/src/relay/external-automations-handler.test.ts +++ b/src/relay/external-automations-handler.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ExternalAutomationsHandler } from './external-automations-handler' import type { RelayDispatcher } from './dispatcher' @@ -15,6 +18,7 @@ const execFileMock = vi.hoisted(() => vi.mock('child_process', () => ({ execFile: execFileMock })) type CapturedHandler = (params?: Record) => Promise +const tempDirs: string[] = [] function createHandlerHarness(): { handler: ExternalAutomationsHandler @@ -34,6 +38,10 @@ beforeEach(() => { execFileMock.mockClear() }) +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + describe('ExternalAutomationsHandler', () => { it('runs external lifecycle actions without shell wrapping', async () => { const { requestHandlers } = createHandlerHarness() @@ -55,26 +63,32 @@ describe('ExternalAutomationsHandler', () => { it('paginates remote Hermes run history after ref lookup', async () => { const { handler, requestHandlers } = createHandlerHarness() const handlerInternals = handler as unknown as { - readHermesRunRefs: (jobId: string) => Promise<{ id: string; run_at: string }[]> + readHermesRunRefs: (jobId: string) => Promise<{ + refs: { id: string; run_at: string }[] + saturated: boolean + }> hydrateHermesRunRef: ( jobId: string, ref: { id: string; run_at: string } ) => Promise<{ id: string; run_at: string }> } - handlerInternals.readHermesRunRefs = vi.fn().mockResolvedValue([ - { - id: 'cron_job-1_20260516_090000', - run_at: '2026-05-16T09:00:00' - }, - { - id: 'job-1:2026-05-15_09-00-00.md', - run_at: '2026-05-15T09:00:00' - }, - { - id: 'job-1:2026-05-14_09-00-00.md', - run_at: '2026-05-14T09:00:00' - } - ]) + handlerInternals.readHermesRunRefs = vi.fn().mockResolvedValue({ + refs: [ + { + id: 'cron_job-1_20260516_090000', + run_at: '2026-05-16T09:00:00' + }, + { + id: 'job-1:2026-05-15_09-00-00.md', + run_at: '2026-05-15T09:00:00' + }, + { + id: 'job-1:2026-05-14_09-00-00.md', + run_at: '2026-05-14T09:00:00' + } + ], + saturated: false + }) handlerInternals.hydrateHermesRunRef = vi.fn(async (_jobId, ref) => ref) const result = (await requestHandlers.get('externalAutomations.runs')?.({ @@ -91,12 +105,80 @@ describe('ExternalAutomationsHandler', () => { ]) }) + it('bounds remote hydration concurrency and omits page-wide output overflow', async () => { + const { handler, requestHandlers } = createHandlerHarness() + const refs = Array.from({ length: 3 }, (_, index) => ({ + id: `run-${index}`, + run_at: `2026-05-15T09:00:0${index}` + })) + let active = 0 + let maxActive = 0 + const handlerInternals = handler as unknown as { + readHermesRunRefs: () => Promise<{ refs: typeof refs; saturated: boolean }> + hydrateHermesRunRef: ( + jobId: string, + ref: (typeof refs)[number] + ) => Promise<{ id: string; output_content: string }> + } + handlerInternals.readHermesRunRefs = vi.fn().mockResolvedValue({ refs, saturated: false }) + handlerInternals.hydrateHermesRunRef = vi.fn(async (_jobId, ref) => { + active += 1 + maxActive = Math.max(maxActive, active) + await new Promise((resolve) => setTimeout(resolve, 1)) + active -= 1 + return { id: ref.id, output_content: 'x'.repeat(16 * 1024 * 1024 + 1) } + }) + + const result = (await requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 3 + })) as { + runs: { id: string; output_content: string | null; error?: string }[] + } + + expect(maxActive).toBe(2) + expect(result.runs.map((run) => run.id)).toEqual(refs.map((ref) => ref.id)) + expect(result.runs[0]?.output_content).toHaveLength(16 * 1024 * 1024 + 1) + expect(result.runs.slice(1)).toEqual([ + expect.objectContaining({ output_content: null, error: expect.stringContaining('omitted') }), + expect.objectContaining({ output_content: null, error: expect.stringContaining('omitted') }) + ]) + }) + + it('omits an oversized sparse remote markdown output without reading it wholesale', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'orca-relay-hermes-output-')) + tempDirs.push(tempDir) + const outputPath = join(tempDir, '2026-05-15_09-00-00.md') + await writeFile(outputPath, '', 'utf-8') + await truncate(outputPath, 64 * 1024 * 1024) + const { handler } = createHandlerHarness() + const handlerInternals = handler as unknown as { + readHermesOutputFileRun: (ref: Record) => Promise + } + + const run = await handlerInternals.readHermesOutputFileRun({ + kind: 'output', + id: 'job-1:2026-05-15_09-00-00.md', + job_id: 'job-1', + run_at: '2026-05-15T09:00:00', + run_key: '20260515_090000', + output_path: outputPath + }) + + expect(run).toMatchObject({ + output_content: null, + error: expect.stringContaining('File too large') + }) + }) + it('uses a count-only path for remote Hermes manager listing run counts', async () => { const { handler, requestHandlers } = createHandlerHarness() const handlerInternals = handler as unknown as { - readHermesRunCount: (jobId: string) => Promise + readHermesRunCount: (jobId: string) => Promise<{ total: number }> } - handlerInternals.readHermesRunCount = vi.fn().mockResolvedValue(42) + handlerInternals.readHermesRunCount = vi.fn().mockResolvedValue({ total: 42 }) const result = (await requestHandlers.get('externalAutomations.runs')?.({ provider: 'hermes', @@ -108,12 +190,34 @@ describe('ExternalAutomationsHandler', () => { expect(result).toEqual({ total: 42, runs: [] }) }) + it('reports when the remote run total is saturated', async () => { + const { handler, requestHandlers } = createHandlerHarness() + const handlerInternals = handler as unknown as { + readHermesRunCount: () => Promise<{ total: number; totalSaturated: true }> + } + handlerInternals.readHermesRunCount = vi + .fn() + .mockResolvedValue({ total: 10_000, totalSaturated: true }) + + await expect( + requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + ).resolves.toEqual({ total: 10_000, totalSaturated: true, runs: [] }) + }) + it('deduplicates concurrent remote Hermes count reads', async () => { const { handler, requestHandlers } = createHandlerHarness() - let resolveRefs: (refs: { id: string; run_at: string }[]) => void = () => {} + let resolveRefs: (result: { + refs: { id: string; run_at: string }[] + saturated: boolean + }) => void = () => {} const readHermesRunRefs = vi.fn( () => - new Promise<{ id: string; run_at: string }[]>((resolve) => { + new Promise<{ refs: { id: string; run_at: string }[]; saturated: boolean }>((resolve) => { resolveRefs = resolve }) ) @@ -136,10 +240,13 @@ describe('ExternalAutomationsHandler', () => { }) expect(readHermesRunRefs).toHaveBeenCalledTimes(1) - resolveRefs([ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, - { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } - ]) + resolveRefs({ + refs: [ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, + { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } + ], + saturated: false + }) await expect(Promise.all([first, second])).resolves.toEqual([ { total: 2, runs: [] }, @@ -151,13 +258,17 @@ describe('ExternalAutomationsHandler', () => { const { handler, requestHandlers } = createHandlerHarness() const readHermesRunRefs = vi .fn() - .mockResolvedValueOnce([ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' } - ]) - .mockResolvedValueOnce([ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, - { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } - ]) + .mockResolvedValueOnce({ + refs: [{ id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }], + saturated: false + }) + .mockResolvedValueOnce({ + refs: [ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, + { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } + ], + saturated: false + }) const handlerInternals = handler as unknown as { readHermesRunRefs: typeof readHermesRunRefs } @@ -200,8 +311,11 @@ describe('ExternalAutomationsHandler', () => { const { handler, requestHandlers } = createHandlerHarness() const readHermesRunRefs = vi.fn(async (jobId: string) => jobId === 'job-0' - ? [{ id: 'job-0:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }] - : [] + ? { + refs: [{ id: 'job-0:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }], + saturated: false + } + : { refs: [], saturated: false } ) const handlerInternals = handler as unknown as { readHermesRunRefs: typeof readHermesRunRefs diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index baaaa99481e..c5cba44800b 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -2,11 +2,28 @@ * run history, and actions must stay co-located behind one relay request handler. */ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { open, readdir, readFile, realpath, stat } from 'node:fs/promises' +import { open, opendir, realpath, stat } from 'node:fs/promises' import { createRequire } from 'node:module' import { homedir } from 'node:os' import { isAbsolute, join, relative, resolve, sep } from 'node:path' import { promisify } from 'node:util' +import { readExternalAutomationJobsFile } from '../shared/external-automation-jobs-file' +import { + type BoundedHermesRunRefs, + HERMES_RUN_REF_MAX_ENTRIES, + HERMES_SESSION_RUN_REFS_SELECT_SQL, + HermesRunRefRetainer +} from '../shared/hermes-run-ref-retention' +import { + formatHermesSessionMessagesWithinLimits, + HERMES_PRIMARY_OUTPUT_MAX_BYTES, + HERMES_RUN_PAGE_MAX_RUNS, + HERMES_SESSION_TRANSCRIPT_SELECT_SQL, + HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR, + hydrateHermesRunPageWithinLimits +} from '../shared/hermes-run-output-limits' +import { readNodeFileWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import { mapWithConcurrency } from '../shared/map-with-concurrency' import type { RelayDispatcher } from './dispatcher' const execFileAsync = promisify(execFile) @@ -23,6 +40,7 @@ const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 +const HERMES_JOB_RUN_COUNT_CONCURRENCY = 4 const FULL_SESSION_LOG_HEADING = '## Full session log' const REFERENCED_LOG_HEADING = '## Latest log file' const LATEST_LOG_PATH_PATTERN = @@ -30,6 +48,7 @@ const LATEST_LOG_PATH_PATTERN = type SqliteStatement = { get: (...args: unknown[]) => Record | undefined all: (...args: unknown[]) => Record[] + iterate?: (...args: unknown[]) => Iterable> } type SqliteDatabase = { prepare: (sql: string) => SqliteStatement @@ -71,9 +90,10 @@ type HermesMergedRunRef = { session: HermesSessionRunRef | null } type HermesRunCountCacheEntry = { - promise: Promise + promise: Promise expiresAt: number } +type HermesRunCount = { total: number; totalSaturated?: true } const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 @@ -106,37 +126,27 @@ export class ExternalAutomationsHandler { if (!existsSync(jobsFile)) { return [] } - const content = await readFile(jobsFile, 'utf-8') - const parsed = JSON.parse(content) as unknown - const jobs = Array.isArray(parsed) - ? parsed - : typeof parsed === 'object' && - parsed !== null && - !Array.isArray(parsed) && - Array.isArray((parsed as { jobs?: unknown }).jobs) - ? (parsed as { jobs: unknown[] }).jobs - : [] + const jobs = await readExternalAutomationJobsFile(jobsFile, { allowRootArray: true }) if (provider !== 'hermes') { return jobs } - return Promise.all( - jobs.map(async (job) => { - if (!this.isRecord(job) || typeof job.id !== 'string') { - return job - } - const runsPage = await this.listRuns({ - provider: 'hermes', - jobId: job.id, - page: 1, - pageSize: 0 - }) - return { - ...job, - run_count: runsPage.total, - runs: runsPage.runs - } + return mapWithConcurrency(jobs, HERMES_JOB_RUN_COUNT_CONCURRENCY, async (job) => { + if (!this.isRecord(job) || typeof job.id !== 'string') { + return job + } + const runsPage = await this.listRuns({ + provider: 'hermes', + jobId: job.id, + page: 1, + pageSize: 0 }) - ) + return { + ...job, + run_count: runsPage.total, + ...(runsPage.totalSaturated ? { run_count_saturated: true } : {}), + runs: runsPage.runs + } + }) } private isRecord(value: unknown): value is Record { @@ -259,9 +269,10 @@ export class ExternalAutomationsHandler { return null } if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { + const { buffer } = await readNodeFileWithinLimit(logPath, MAX_REFERENCED_LOG_BYTES) return { path: logPath, - content: await readFile(logPath, 'utf-8'), + content: buffer.toString('utf-8'), truncated: false } } @@ -313,31 +324,6 @@ export class ExternalAutomationsHandler { .join('\n\n') } - private formatSessionMessages(messages: Record[]): string | null { - if (messages.length === 0) { - return null - } - return messages - .map((message) => { - const role = typeof message.role === 'string' ? message.role : 'message' - const content = typeof message.content === 'string' ? message.content.trim() : '' - const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' - const reasoning = - typeof message.reasoning_content === 'string' - ? message.reasoning_content.trim() - : typeof message.reasoning === 'string' - ? message.reasoning.trim() - : '' - const parts = [ - `## ${role}${toolName ? ` / ${toolName}` : ''}`, - reasoning ? `### Reasoning\n\n${reasoning}` : null, - content || '(empty)' - ].filter(Boolean) - return parts.join('\n\n') - }) - .join('\n\n---\n\n') - } - private getRunKey(run: unknown): string | null { return this.isRecord(run) && typeof run.run_key === 'string' && run.run_key.trim() ? run.run_key @@ -535,19 +521,20 @@ export class ExternalAutomationsHandler { return databaseConstructor } - private async readHermesRunRefs(jobId: string): Promise { + private async readHermesRunRefs( + jobId: string + ): Promise> { const outputRuns = await this.readHermesOutputFileRunRefs(jobId) - return this.mergeHermesOutputAndSessionRunRefs( - outputRuns, - this.readHermesSessionDbRunRefs(jobId) - ).sort((a, b) => { - const aTime = this.getRawRunTime(a) - const bTime = this.getRawRunTime(b) - if (Number.isFinite(aTime) && Number.isFinite(bTime)) { - return bTime - aTime - } - return this.getRawRunId(b).localeCompare(this.getRawRunId(a)) - }) + const sessionRuns = this.readHermesSessionDbRunRefs(jobId) + const mergedRetainer = new HermesRunRefRetainer() + for (const ref of this.mergeHermesOutputAndSessionRunRefs(outputRuns.refs, sessionRuns.refs)) { + mergedRetainer.add(ref) + } + const merged = mergedRetainer.finish() + return { + refs: merged.refs, + saturated: outputRuns.saturated || sessionRuns.saturated || merged.saturated + } } private async hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise { @@ -564,9 +551,9 @@ export class ExternalAutomationsHandler { ) } - private async readHermesRunCount(jobId: string): Promise { + private async readHermesRunCount(jobId: string): Promise { if (!EXTERNAL_JOB_ID_PATTERN.test(jobId)) { - return 0 + return { total: 0 } } const now = Date.now() const cached = this.hermesRunCountCache.get(jobId) @@ -580,7 +567,10 @@ export class ExternalAutomationsHandler { // processes are long-lived, so stale job ids need both TTL and a hard cap. this.pruneHermesRunCountCache(now) const entry: HermesRunCountCacheEntry = { - promise: this.readHermesRunRefs(jobId).then((refs) => refs.length), + promise: this.readHermesRunRefs(jobId).then((result) => ({ + total: result.refs.length, + ...(result.saturated ? { totalSaturated: true as const } : {}) + })), expiresAt: Number.POSITIVE_INFINITY } this.hermesRunCountCache.set(jobId, entry) @@ -621,6 +611,7 @@ export class ExternalAutomationsHandler { private async listRuns(params: Record = {}): Promise<{ total: number + totalSaturated?: true runs: unknown[] }> { const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' @@ -631,7 +622,7 @@ export class ExternalAutomationsHandler { : 1 const pageSize = typeof params.pageSize === 'number' && Number.isFinite(params.pageSize) - ? Math.min(100, Math.max(0, Math.floor(params.pageSize))) + ? Math.min(HERMES_RUN_PAGE_MAX_RUNS, Math.max(0, Math.floor(params.pageSize))) : 25 if (provider !== 'hermes') { return { total: 0, runs: [] } @@ -642,53 +633,52 @@ export class ExternalAutomationsHandler { if (pageSize === 0) { // Why: manager listing only needs a badge count; hydrating markdown logs // and full session transcripts can make opening Automations very slow. - return { total: await this.readHermesRunCount(jobId), runs: [] } + return { ...(await this.readHermesRunCount(jobId)), runs: [] } } const runRefs = await this.readHermesRunRefs(jobId) const start = (page - 1) * pageSize return { - total: runRefs.length, - runs: await Promise.all( - runRefs.slice(start, start + pageSize).map((ref) => this.hydrateHermesRunRef(jobId, ref)) + total: runRefs.refs.length, + ...(runRefs.saturated ? { totalSaturated: true } : {}), + runs: await hydrateHermesRunPageWithinLimits( + runRefs.refs.slice(start, start + pageSize), + (ref) => this.hydrateHermesRunRef(jobId, ref) ) } } - private getRawRunId(run: unknown): string { - if (this.isRecord(run) && 'id' in run) { - return String(run.id) - } - return '' - } - - private getRawRunTime(run: unknown): number { - if (!this.isRecord(run) || !('run_at' in run)) { - return Number.NaN - } - return typeof run.run_at === 'string' ? Date.parse(run.run_at) : Number.NaN - } - - private async readHermesOutputFileRunRefs(jobId: string): Promise { + private async readHermesOutputFileRunRefs( + jobId: string + ): Promise> { const outputDir = join(HERMES_OUTPUT_DIR, jobId) if (!existsSync(outputDir)) { - return [] + return { refs: [], saturated: false } } - const entries = await readdir(outputDir, { withFileTypes: true }) - return entries - .filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) - .map((entry) => ({ + const retainer = new HermesRunRefRetainer() + const directory = await opendir(outputDir) + for await (const entry of directory) { + if (!entry.isFile() || !HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) { + continue + } + retainer.add({ kind: 'output' as const, id: `${jobId}:${entry.name}`, job_id: jobId, run_at: this.runAtFromHermesOutputFile(entry.name), run_key: this.runKeyFromHermesOutputFile(entry.name), output_path: join(outputDir, entry.name) - })) + }) + } + return retainer.finish() } private async readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { try { - const content = await readFile(ref.output_path, 'utf-8') + const { buffer } = await readNodeFileWithinLimit( + ref.output_path, + HERMES_PRIMARY_OUTPUT_MAX_BYTES + ) + const content = buffer.toString('utf-8') const parsed = this.parseHermesOutput(content) const outputContent = await this.appendReferencedLogFile(parsed.outputContent) return { @@ -717,41 +707,45 @@ export class ExternalAutomationsHandler { } } - private readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { + private readHermesSessionDbRunRefs(jobId: string): BoundedHermesRunRefs { if (!existsSync(HERMES_STATE_DB)) { - return [] + return { refs: [], saturated: false } } const Database = this.getDatabaseConstructor() if (!Database) { - return [] + return { refs: [], saturated: false } } try { const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) try { const pattern = `cron\\_${this.escapeSqlLike(jobId)}\\_%` - const rows = db - .prepare( - `SELECT id, started_at - FROM sessions - WHERE id LIKE ? ESCAPE '\\' - ORDER BY started_at DESC` - ) - .all(pattern) as Record[] - return rows.map((row) => { + const statement = db.prepare(HERMES_SESSION_RUN_REFS_SELECT_SQL) + const rows = + typeof statement.iterate === 'function' + ? statement.iterate(pattern) + : statement.all(pattern) + const retainer = new HermesRunRefRetainer() + let read = 0 + for (const row of rows) { + if (read >= HERMES_RUN_REF_MAX_ENTRIES + 1) { + break + } + read += 1 const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}` - return { + retainer.add({ kind: 'session', id: runId, job_id: jobId, run_at: this.runAtFromUnixSeconds(row.started_at), run_key: runId.split(`${jobId}_`).at(-1) ?? null - } - }) + }) + } + return retainer.finish() } finally { db.close() } } catch { - return [] + return { refs: [], saturated: false } } } @@ -777,14 +771,12 @@ export class ExternalAutomationsHandler { if (!row) { return null } - const messages = db - .prepare( - `SELECT role, content, tool_name, reasoning, reasoning_content - FROM messages - WHERE session_id = ? - ORDER BY timestamp, id` - ) - .all(runId) as Record[] + const messageStatement = db.prepare(HERMES_SESSION_TRANSCRIPT_SELECT_SQL) + const messages = + typeof messageStatement.iterate === 'function' + ? messageStatement.iterate(runId) + : messageStatement.all(runId) + const formattedMessages = formatHermesSessionMessagesWithinLimits(messages) const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null const messageCount = typeof row.message_count === 'number' ? row.message_count : null @@ -804,8 +796,8 @@ export class ExternalAutomationsHandler { run_key: runId.split(`${jobId}_`).at(-1) ?? null, status: typeof row.ended_at === 'number' ? 'completed' : 'unknown', output_preview: summaryParts.join(' · ') || null, - output_content: this.formatSessionMessages(messages), - error: null, + output_content: formattedMessages.content, + error: formattedMessages.truncated ? HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR : null, output_path: HERMES_STATE_DB } } finally { diff --git a/src/relay/filesystem-directory-reader.test.ts b/src/relay/filesystem-directory-reader.test.ts new file mode 100644 index 00000000000..6a7d4387a64 --- /dev/null +++ b/src/relay/filesystem-directory-reader.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { FILESYSTEM_DIRECTORY_LIMIT_MESSAGE } from '../shared/filesystem-directory-listing-limit' +import { collectRelayFilesystemDirectoryEntries } from './filesystem-directory-reader' + +function entry(name: string, options?: { directory?: boolean; symlink?: boolean }) { + return { + name, + isDirectory: () => options?.directory ?? false, + isSymbolicLink: () => options?.symlink ?? false + } +} + +describe('relay filesystem directory reader', () => { + it('preserves directory-first ordering and symlink-directory classification', async () => { + const classify = vi.fn(async (_dirPath, source) => + source.isSymbolicLink() ? true : source.isDirectory() + ) + + await expect( + collectRelayFilesystemDirectoryEntries( + '/repo', + [ + entry('z.txt'), + entry('linked', { symlink: true }), + entry('beta', { directory: true }), + entry('alpha', { directory: true }) + ], + undefined, + classify + ) + ).resolves.toEqual([ + { name: 'alpha', isDirectory: true, isSymlink: false }, + { name: 'beta', isDirectory: true, isSymlink: false }, + { name: 'linked', isDirectory: true, isSymlink: true }, + { name: 'z.txt', isDirectory: false, isSymlink: false } + ]) + expect(classify).toHaveBeenCalledTimes(4) + }) + + it('stops before classifying or retaining the first over-limit entry', async () => { + let enumerated = 0 + const classify = vi.fn(async () => false) + const source = { + async *[Symbol.asyncIterator]() { + while (enumerated < 100) { + enumerated += 1 + yield entry(`entry-${enumerated}`) + } + } + } + + await expect( + collectRelayFilesystemDirectoryEntries( + '/repo', + source, + { maxEntries: 3, maxRetainedBytes: 1024 }, + classify + ) + ).rejects.toThrow(FILESYSTEM_DIRECTORY_LIMIT_MESSAGE) + expect(enumerated).toBe(4) + expect(classify).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/relay/filesystem-directory-reader.ts b/src/relay/filesystem-directory-reader.ts new file mode 100644 index 00000000000..e6c6d6e46c4 --- /dev/null +++ b/src/relay/filesystem-directory-reader.ts @@ -0,0 +1,70 @@ +import { opendir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { + createFilesystemDirectoryLimitState, + trackFilesystemDirectoryEntry, + type FilesystemDirectoryListingLimits +} from '../shared/filesystem-directory-listing-limit' + +type RelayDirectorySourceEntry = { + name: string + isDirectory(): boolean + isSymbolicLink(): boolean +} + +export type RelayFilesystemDirectoryEntry = { + name: string + isDirectory: boolean + isSymlink: boolean +} + +type DirectoryClassifier = (dirPath: string, entry: RelayDirectorySourceEntry) => Promise + +async function isRelayFilesystemDirectoryEntry( + dirPath: string, + entry: RelayDirectorySourceEntry +): Promise { + if (entry.isDirectory()) { + return true + } + if (!entry.isSymbolicLink()) { + return false + } + try { + // Why: linked directories must remain expandable remotely. + return (await stat(join(dirPath, entry.name))).isDirectory() + } catch { + return false + } +} + +export async function readRelayFilesystemDirectory( + dirPath: string, + requestedLimits?: Partial +): Promise { + return collectRelayFilesystemDirectoryEntries(dirPath, await opendir(dirPath), requestedLimits) +} + +export async function collectRelayFilesystemDirectoryEntries( + dirPath: string, + directory: AsyncIterable | Iterable, + requestedLimits?: Partial, + classifyDirectory: DirectoryClassifier = isRelayFilesystemDirectoryEntry +): Promise { + const entries: RelayFilesystemDirectoryEntry[] = [] + const limit = createFilesystemDirectoryLimitState(requestedLimits) + for await (const entry of directory) { + trackFilesystemDirectoryEntry(limit, entry) + entries.push({ + name: entry.name, + isDirectory: await classifyDirectory(dirPath, entry), + isSymlink: entry.isSymbolicLink() + }) + } + return entries.sort((left, right) => { + if (left.isDirectory !== right.isDirectory) { + return left.isDirectory ? -1 : 1 + } + return left.name.localeCompare(right.name) + }) +} diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index 003172c08fd..d618084d59c 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -1,5 +1,12 @@ -import { open, readFile, stat } from 'node:fs/promises' +import { open, stat } from 'node:fs/promises' import { extname } from 'node:path' +import { readNodeFileWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import { + assertRasterImagePreviewWithinLimits, + isKnownRasterImageMimeType, + RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES +} from '../shared/raster-image-preview-limits' +import type { RasterImageDimensions } from '../shared/raster-image-dimensions' import type { RelayDispatcher, RequestContext } from './dispatcher' import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry' @@ -23,15 +30,22 @@ export async function readRelayFileContent(filePath: string) { } if (mimeType) { - const buffer = await readFile(filePath) - return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } + const { buffer } = await readNodeFileWithinLimit(filePath, sizeLimit) + const imageDimensions = assertRasterImagePreviewWithinLimits(buffer, mimeType) + return { + content: buffer.toString('base64'), + isBinary: true, + isImage: true, + mimeType, + ...(imageDimensions ? { imageDimensions } : {}) + } } if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { return { content: '', isBinary: true } } - const buffer = await readFile(filePath) + const { buffer } = await readNodeFileWithinLimit(filePath, sizeLimit) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } @@ -44,6 +58,7 @@ export type StreamMetadata = { isBinary: boolean isImage?: boolean mimeType?: string + imageDimensions?: RasterImageDimensions /** On-the-wire encoding of each chunk's `data` field. Always 'base64'. */ chunkEncoding?: 'base64' /** Encoding of the assembled FileReadResult.content. */ @@ -105,31 +120,38 @@ export async function readRelayFileStreamMetadata( const handle = await open(filePath, 'r') let streamId: number try { - streamId = registry.register(handle) + let imageDimensions: RasterImageDimensions | undefined + if (isKnownRasterImageMimeType(mimeType)) { + const header = Buffer.alloc(Math.min(stats.size, RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES)) + const headerBytesRead = await readFullStreamChunk(handle, header, header.length, 0) + imageDimensions = assertRasterImagePreviewWithinLimits( + header.subarray(0, headerBytesRead), + mimeType + ) + } + streamId = registry.register(handle, context.clientId) + process.stderr.write(`[relay] stream start id=${streamId} size=${stats.size}\n`) + + // Why: start after metadata returns so the subscribed client cannot miss the first chunk. + const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } + setImmediate(() => { + void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions) + }) + + return { + streamId, + totalSize: stats.size, + isBinary: !!mimeType, + isImage: mimeType ? true : undefined, + mimeType, + ...(imageDimensions ? { imageDimensions } : {}), + chunkEncoding: 'base64', + resultEncoding: mimeType ? 'base64' : 'utf-8' + } } catch (err) { await handle.close() throw err } - - process.stderr.write(`[relay] stream start id=${streamId} size=${stats.size}\n`) - - // Why: pumpChunks owns its own try/finally for handle release; the outer - // setImmediate kicks the pump off the metadata-response task so the client - // sees the response before the first chunk frame. - const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } - setImmediate(() => { - void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions) - }) - - return { - streamId, - totalSize: stats.size, - isBinary: !!mimeType, - isImage: mimeType ? true : undefined, - mimeType, - chunkEncoding: 'base64', - resultEncoding: mimeType ? 'base64' : 'utf-8' - } } async function pumpChunks( @@ -203,6 +225,7 @@ async function pumpChunks( // Why: the bulk lane waits out sink saturation, so a flood of chunk // frames cannot pile up in the outbound pipe ahead of interactive // pty.data frames written via plain notify(). + registry.recordSent(streamId, seq) await dispatcher.notifyBulk( 'fs.streamChunk', { streamId, seq, data }, @@ -227,14 +250,23 @@ async function pumpChunks( try { if (endReason === 'end') { - dispatcher.notify('fs.streamEnd', { streamId }) + if (pumpOptions.clientId !== undefined) { + dispatcher.notifyClient(pumpOptions.clientId, 'fs.streamEnd', { streamId }) + } else { + dispatcher.notify('fs.streamEnd', { streamId }) + } process.stderr.write(`[relay] stream end id=${streamId}\n`) } else if (endReason === 'error') { - dispatcher.notify('fs.streamError', { + const params = { streamId, code: errorCode ?? 'ESTREAMERROR', message: errorMessage ?? 'stream error' - }) + } + if (pumpOptions.clientId !== undefined) { + dispatcher.notifyClient(pumpOptions.clientId, 'fs.streamError', params) + } else { + dispatcher.notify('fs.streamError', params) + } process.stderr.write(`[relay] stream error id=${streamId} code=${errorCode}\n`) } else if (endReason === 'aborted') { process.stderr.write(`[relay] stream cancel id=${streamId}\n`) diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index 8de2ad3394c..66f0e48d2ad 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -27,6 +27,14 @@ import { SEARCH_TIMEOUT_MS } from '../shared/text-search' import { buildRelayGitEnv } from './relay-command-env' +import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' +import { + createQuickOpenListingBudget, + QUICK_OPEN_LISTING_MAX_PATH_BYTES, + QuickOpenSubprocessPathAccumulator, + resolveQuickOpenResultLimit, + retainQuickOpenPath +} from '../shared/quick-open-listing-limits' /** * List files using `git ls-files`. Fallback when rg is not installed. @@ -46,9 +54,14 @@ export function listFilesWithGit( if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } + const resultLimit = resolveQuickOpenResultLimit(maxResults) + if (resultLimit === 0) { + return Promise.resolve([]) + } const gitPaths = new Set() const directoryPaths = new Set() const directFileCandidates = new Set() + const listingBudget = createQuickOpenListingBudget() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ReturnType @@ -59,7 +72,7 @@ export function listFilesWithGit( const runGitLsFiles = (args: string[]): Promise => { return new Promise((resolve, reject) => { - let buf = '' + const paths = new QuickOpenSubprocessPathAccumulator(0) let done = false const processPath = (path: string): boolean => { @@ -67,27 +80,23 @@ export function listFilesWithGit( return false } if (path.endsWith('/')) { - directoryPaths.add(path) + retainQuickOpenPath(directoryPaths, path, listingBudget) } else { - gitPaths.add(path) - if (maxResults !== undefined) { - // Why: this duplicate classification exists only to stop bounded - // scans; unbounded SSH scans must not retain another full listing. - const parsed = parseQuickOpenGitLsFilesEntry(path) - const relPath = parsed.path.replace(/\/+$/, '') - if ( - !parsed.isGitlink && - !parsed.isUntrackedDir && - shouldIncludeQuickOpenPath(relPath) && - !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) - ) { - directFileCandidates.add(relPath) - } + retainQuickOpenPath(gitPaths, path, listingBudget) + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + retainQuickOpenPath(directFileCandidates, relPath, listingBudget) } } // Why: placeholders need IO classification and can disappear; only // guaranteed final files are allowed to stop the remote Git processes. - return maxResults !== undefined && directFileCandidates.size >= maxResults + return directFileCandidates.size >= resultLimit } const child = spawn('git', ['ls-files', ...args], { @@ -111,7 +120,7 @@ export function listFilesWithGit( return } done = true - buf = '' + paths.clear() cleanup() reject(error) } @@ -120,6 +129,7 @@ export function listFilesWithGit( return } done = true + paths.clear() cleanup() resolve() } @@ -130,20 +140,23 @@ export function listFilesWithGit( resolve: resolvePass }) - function handleStdoutData(chunk: string): void { - buf += chunk - let start = 0 - let idx = buf.indexOf('\0', start) - while (idx !== -1) { - if (processPath(buf.substring(start, idx))) { - buf = '' + function failForOutput(error: unknown): void { + child.kill() + rejectPass(error instanceof Error ? error : new Error(String(error))) + } + function handleStdoutData(chunk: Buffer | string): void { + try { + const outcome = paths.push(chunk, (path) => !processPath(path)) + if (outcome === 'stopped') { finishAtLimit() - return + } else if (outcome === 'path-too-large') { + failForOutput( + new Error(`Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes`) + ) } - start = idx + 1 - idx = buf.indexOf('\0', start) + } catch (error) { + failForOutput(error) } - buf = start < buf.length ? buf.substring(start) : '' } function handleStderrData(): void { /* drain */ @@ -162,9 +175,14 @@ export function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - if (buf && processPath(buf)) { - buf = '' - finishAtLimit() + try { + const trailingPath = paths.finish() + if (trailingPath && processPath(trailingPath)) { + finishAtLimit() + return + } + } catch (error) { + failForOutput(error) return } if (code === 0) { @@ -177,7 +195,6 @@ export function listFilesWithGit( rejectPass(new Error(`git ls-files exited with code ${code}`)) } - child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) @@ -232,12 +249,9 @@ export function listFilesWithGit( ) } }) - const passes = - maxResults === undefined - ? Promise.all([runGitLsFiles(primary), runIgnoredPass()]) - : runGitLsFiles(primary).then(() => - directFileCandidates.size < maxResults ? runIgnoredPass() : Promise.resolve() - ) + const passes = runGitLsFiles(primary).then(() => + directFileCandidates.size < resultLimit ? runIgnoredPass() : Promise.resolve() + ) return passes .then(async () => { @@ -247,11 +261,11 @@ export function listFilesWithGit( directoryPaths, excludePathPrefixes, signal, - maxResults + maxResults: resultLimit }) // Why: directory placeholders are expanded after Git exits; restore // Git's path order for empty queries and fuzzy-score ties over SSH. - return files.sort().slice(0, maxResults) + return files.sort().slice(0, resultLimit) }) .catch((err) => { killSurvivors('git ls-files canceled after sibling failure') @@ -277,7 +291,7 @@ export function searchWithGitGrep( const gitArgs = buildGitGrepArgs(query, opts) const matchRegex = buildSubmatchRegex(query, opts) const acc = createAccumulator() - let stdoutBuffer = '' + const stdoutLines = new SearchSubprocessLineAccumulator() let done = false const child = spawn('git', gitArgs, { @@ -309,12 +323,11 @@ export function searchWithGitGrep( } } - function handleStdoutData(chunk: string): void { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const l of lines) { - processLine(l) + function handleStdoutData(chunk: Buffer): void { + if (!stdoutLines.push(chunk, processLine)) { + acc.truncated = true + child.kill() + resolveOnce() } } @@ -327,13 +340,13 @@ export function searchWithGitGrep( } function handleClose(): void { - if (stdoutBuffer) { - processLine(stdoutBuffer) + const trailingLine = stdoutLines.finish() + if (trailingLine !== null) { + processLine(trailingLine) } resolveOnce() } - child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index 02cd8da6afc..e30bf3cc498 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -43,7 +43,7 @@ describe('relay list-files cancellation', () => { vi.useRealTimers() }) - it('listFilesWithRg kills both rg passes and rejects when aborted mid-flight', async () => { + it('listFilesWithRg kills the active rg pass and rejects when aborted mid-flight', async () => { const primaryProc = createMockProcess() const ignoredProc = createMockProcess() spawnMock.mockImplementation((_cmd: string, args: string[]) => @@ -59,7 +59,8 @@ describe('relay list-files cancellation', () => { await expect(promise).rejects.toSatisfy(isFileListingCancellation) expect(primaryProc.kill).toHaveBeenCalled() - expect(ignoredProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) // Late close events after cancellation must not fire anything. primaryProc.emit('close', null, 'SIGTERM') @@ -89,14 +90,16 @@ describe('relay list-files cancellation', () => { setTimeout(() => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') primaryProc.emit('close', 0, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') - ignoredProc.emit('close', 0, null) + queueMicrotask(() => { + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') + ignoredProc.emit('close', 0, null) + }) }, 5) await expect(promise).resolves.toEqual(['src/index.ts', 'dist/out.js']) }) - it('listFilesWithGit kills both git passes and rejects when aborted mid-flight', async () => { + it('listFilesWithGit kills the active git pass and rejects when aborted mid-flight', async () => { const procs: ChildProcess[] = [] spawnMock.mockImplementation(() => { const proc = createMockProcess() @@ -107,12 +110,11 @@ describe('relay list-files cancellation', () => { const controller = new AbortController() const promise = listFilesWithGit('/remote/root', [], { signal: controller.signal }) - expect(procs).toHaveLength(2) + expect(procs).toHaveLength(1) controller.abort() await expect(promise).rejects.toSatisfy(isFileListingCancellation) expect(procs[0].kill).toHaveBeenCalled() - expect(procs[1].kill).toHaveBeenCalled() }) it('listFilesWithGit rejects without spawning when the signal is already aborted', async () => { diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 1ca5c2f5327..acc1450cdc0 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -16,6 +16,10 @@ import { tmpdir } from 'node:os' import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithRg } from './fs-handler-list-files' import { searchWithRg } from './fs-handler-utils' +import { + QUICK_OPEN_LISTING_MAX_PATH_BYTES, + QUICK_OPEN_LISTING_MAX_RESULTS +} from '../shared/quick-open-listing-limits' const tempDirs: string[] = [] const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -77,10 +81,12 @@ describe('relay quick open ignored file listing', () => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') primaryProc.emit('close', 0, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') - ignoredProc.emit('close', 0, null) + queueMicrotask(() => { + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') + ignoredProc.emit('close', 0, null) + }) }, 10) await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) @@ -115,6 +121,46 @@ describe('relay quick open ignored file listing', () => { expect(callIndex).toBe(1) }) + it('bounds an omitted relay rg result limit without spawning the ignored pass', async () => { + const primary = createMockProcess() + const ignored = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) + const promise = listFilesWithRg('/remote/root') + const paths = Array.from( + { length: QUICK_OPEN_LISTING_MAX_RESULTS + 1 }, + (_value, index) => `src/file-${index}.ts` + ) + + ;(primary.stdout as unknown as EventEmitter).emit('data', paths.join('\n')) + + const result = await promise + expect(result).toHaveLength(QUICK_OPEN_LISTING_MAX_RESULTS) + expect(primary.kill).toHaveBeenCalled() + expect(ignored.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + + it('kills a relay rg scan whose residual path exceeds the field limit', async () => { + const primary = createMockProcess() + const ignored = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) + const promise = listFilesWithRg('/remote/root') + + ;(primary.stdout as unknown as EventEmitter).emit( + 'data', + Buffer.alloc(QUICK_OPEN_LISTING_MAX_PATH_BYTES + 1, 0x61) + ) + + await expect(promise).rejects.toThrow( + `Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes` + ) + expect(primary.kill).toHaveBeenCalled() + expect(ignored.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + it('git fallback ignored pass includes ignored non-env files', async () => { const root = await makeTempRoot() await writeRel(root, 'dist/generated.js') @@ -140,9 +186,11 @@ describe('relay quick open ignored file listing', () => { ) primaryProc.emit('close', 0, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/\0') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\0') - ignoredProc.emit('close', 0, null) + queueMicrotask(() => { + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/\0') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\0') + ignoredProc.emit('close', 0, null) + }) }, 10) await expect(promise).resolves.toEqual(['dist/generated.js', 'src/index.ts', 'tab\tfile.txt']) @@ -179,6 +227,26 @@ describe('relay quick open ignored file listing', () => { expect(callIndex).toBe(1) }) + it('bounds an omitted relay Git result limit before spawning the ignored pass', async () => { + const primary = createMockProcess() + const ignored = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) + const promise = listFilesWithGit('/remote/root') + const paths = Array.from( + { length: QUICK_OPEN_LISTING_MAX_RESULTS + 1 }, + (_value, index) => `src/file-${index}.ts` + ) + + ;(primary.stdout as unknown as EventEmitter).emit('data', paths.join('\0')) + + const result = await promise + expect(result).toHaveLength(QUICK_OPEN_LISTING_MAX_RESULTS) + expect(primary.kill).toHaveBeenCalled() + expect(ignored.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + it('does not let a discarded relay Git placeholder consume the result budget', async () => { const primaryProc = createMockProcess() spawnMock.mockReturnValue(primaryProc) @@ -219,7 +287,7 @@ describe('relay quick open ignored file listing', () => { `${staged('100644', 'README.md')}\0${staged('160000', 'packages/app')}\0packages/lib/\0` ) primaryProc.emit('close', 0, null) - ignoredProc.emit('close', 0, null) + queueMicrotask(() => ignoredProc.emit('close', 0, null)) }, 10) await expect(promise).resolves.toEqual([ @@ -247,9 +315,11 @@ describe('relay quick open ignored file listing', () => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) - // Entries streamed before the kill are kept alongside the primary pass. - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') - ignoredProc.emit('close', null, 'SIGTERM') + queueMicrotask(() => { + // Entries streamed before the kill are kept alongside the primary pass. + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.emit('close', null, 'SIGTERM') + }) }, 10) await expect(promise).resolves.toEqual(['dist/generated.js', 'src/index.ts']) @@ -277,7 +347,7 @@ describe('relay quick open ignored file listing', () => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) - ignoredProc.emit('close', 128, null) + queueMicrotask(() => ignoredProc.emit('close', 128, null)) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -302,12 +372,10 @@ describe('relay quick open ignored file listing', () => { setTimeout(() => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') primaryProc.emit('close', null, 'SIGTERM') - - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') - ignoredProc.emit('close', 0, null) }, 10) await expect(promise).rejects.toThrow('git ls-files killed by SIGTERM') + expect(callIndex).toBe(1) }) it('git fallback rejects when the primary pass exits non-zero', async () => { @@ -324,12 +392,10 @@ describe('relay quick open ignored file listing', () => { setTimeout(() => { primaryProc.emit('close', 128, null) - - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') - ignoredProc.emit('close', 0, null) }, 10) await expect(promise).rejects.toThrow('git ls-files exited with code 128') + expect(callIndex).toBe(1) }) it('git fallback rejects when a timed-out child does not emit close', async () => { @@ -355,15 +421,12 @@ describe('relay quick open ignored file listing', () => { expect(outcome).toContain('git ls-files timed out') expect(primaryProc.kill).toHaveBeenCalled() - expect(ignoredProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(primaryProc.listenerCount('error')).toBe(0) expect(primaryProc.listenerCount('close')).toBe(0) - expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(ignoredProc.listenerCount('error')).toBe(0) - expect(ignoredProc.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() } @@ -392,15 +455,12 @@ describe('relay quick open ignored file listing', () => { expect(outcome).toBe('rejected:rg list timed out') expect(primaryProc.kill).toHaveBeenCalled() - expect(ignoredProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(primaryProc.listenerCount('error')).toBe(0) expect(primaryProc.listenerCount('close')).toBe(0) - expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(ignoredProc.listenerCount('error')).toBe(0) - expect(ignoredProc.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() } diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index c63a352786e..b6586c1af7e 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -23,6 +23,13 @@ import { shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../shared/quick-open-filter' +import { + createQuickOpenListingBudget, + QUICK_OPEN_LISTING_MAX_PATH_BYTES, + QuickOpenSubprocessPathAccumulator, + resolveQuickOpenResultLimit, + retainQuickOpenPath +} from '../shared/quick-open-listing-limits' export const LIST_FILES_TIMEOUT_MS = 25_000 @@ -35,8 +42,13 @@ export function listFilesWithRg( if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } + const resultLimit = resolveQuickOpenResultLimit(maxResults) + if (resultLimit === 0) { + return Promise.resolve([]) + } return new Promise((resolve, reject) => { const files = new Set() + const listingBudget = createQuickOpenListingBudget() let done = false const children: { child: ChildProcess @@ -66,16 +78,15 @@ export function listFilesWithRg( if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { return true } - files.add(relPath) - if (maxResults !== undefined && files.size >= maxResults) { - finishAtLimit() + if (files.size < resultLimit) { + retainQuickOpenPath(files, relPath, listingBudget) } return true } const runPass = (args: string[]): Promise => new Promise((passResolve, passReject) => { - let passBuf = '' + const paths = new QuickOpenSubprocessPathAccumulator(0x0a) let passDone = false let passFileCount = 0 // --no-messages: permission-denied noise on the remote (e.g. .ssh, @@ -104,7 +115,7 @@ export function listFilesWithRg( return } passDone = true - passBuf = '' + paths.clear() cleanup() passReject(error) } @@ -113,6 +124,7 @@ export function listFilesWithRg( return } passDone = true + paths.clear() cleanup() passResolve() } @@ -129,21 +141,30 @@ export function listFilesWithRg( rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) - function handleStdoutData(chunk: string): void { - passBuf += chunk - let start = 0 - let idx = passBuf.indexOf('\n', start) - while (idx !== -1) { - if (processLine(passBuf.substring(start, idx))) { - passFileCount++ + function failForOutput(error: unknown): void { + child.kill() + rejectPass(error instanceof Error ? error : new Error(String(error))) + } + function handleStdoutData(chunk: Buffer | string): void { + try { + const outcome = paths.push(chunk, (path) => { + if (processLine(path)) { + passFileCount++ + } + return files.size < resultLimit + }) + if (outcome === 'stopped') { + finishAtLimit() + } else if (outcome === 'path-too-large') { + failForOutput( + new Error( + `Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes` + ) + ) } - if (done) { - return - } - start = idx + 1 - idx = passBuf.indexOf('\n', start) + } catch (error) { + failForOutput(error) } - passBuf = start < passBuf.length ? passBuf.substring(start) : '' } function handleStderrData(): void { /* drain to prevent backpressure stalls */ @@ -163,10 +184,18 @@ export function listFilesWithRg( return } // Flush residual line only on clean exit. - if (passBuf) { - if (processLine(passBuf)) { + try { + const trailingPath = paths.finish() + if (trailingPath && processLine(trailingPath)) { passFileCount++ } + if (files.size >= resultLimit) { + finishAtLimit() + return + } + } catch (error) { + failForOutput(error) + return } // exit 0 = matches found, 1 = no files (still success for --files). // exit 2 is documented as "a subdirectory could not be searched" @@ -182,7 +211,6 @@ export function listFilesWithRg( } } - child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) @@ -212,7 +240,7 @@ export function listFilesWithRg( done = true signal?.removeEventListener('abort', onAbort) killSurvivors('rg list reached bounded result limit') - resolve(Array.from(files).slice(0, maxResults)) + resolve(Array.from(files).slice(0, resultLimit)) } // Why: a cancelled scan (workspace switch, superseded request) must stop @@ -228,14 +256,11 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) - const passes = - maxResults === undefined - ? Promise.all([runPass(primary), runPass(ignoredPass)]) - : // Why: deterministic primary-first budgeting prevents a large ignored - // tree from starving ordinary source paths on a remote host. - runPass(primary).then(() => - files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() - ) + // Why: deterministic primary-first budgeting prevents a large ignored + // tree from starving ordinary source paths on a remote host. + const passes = runPass(primary).then(() => + files.size < resultLimit ? runPass(ignoredPass) : Promise.resolve() + ) passes .then(() => { @@ -244,7 +269,7 @@ export function listFilesWithRg( } done = true signal?.removeEventListener('abort', onAbort) - resolve(Array.from(files)) + resolve(Array.from(files).slice(0, resultLimit)) }) .catch((err) => { if (done) { diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index c7624afce65..7490fa7d2f9 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -5,19 +5,25 @@ import type { RelayDispatcher } from './dispatcher' import { STREAM_CHUNK_SIZE } from './protocol' import * as fs from 'node:fs/promises' import * as path from 'node:path' -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) -type Notification = { method: string; params?: Record } +type Notification = { method: string; params?: Record; clientId?: number } function createMockDispatcher() { const requestHandlers = new Map< string, - (params: Record, context?: { isStale: () => boolean }) => Promise + ( + params: Record, + context?: { clientId: number; isStale: () => boolean } + ) => Promise + >() + const notificationHandlers = new Map< + string, + (params: Record, context: { clientId: number; isStale: () => boolean }) => void >() - const notificationHandlers = new Map) => void>() const notifications: Notification[] = [] return { onRequest: vi.fn( @@ -28,33 +34,55 @@ function createMockDispatcher() { requestHandlers.set(method, handler as never) } ), - onNotification: vi.fn((method: string, handler: (params: Record) => void) => { - notificationHandlers.set(method, handler) - }), + onNotification: vi.fn( + ( + method: string, + handler: ( + params: Record, + context: { clientId: number; isStale: () => boolean } + ) => void + ) => { + notificationHandlers.set(method, handler) + } + ), notify: vi.fn((method: string, params?: Record) => { notifications.push({ method, params }) }), - notifyBulk: vi.fn(async (method: string, params?: Record): Promise => { - notifications.push({ method, params }) - }), + notifyClient: vi.fn( + (clientId: number, method: string, params?: Record): void => { + notifications.push({ method, params, clientId }) + } + ), + notifyBulk: vi.fn( + async ( + method: string, + params?: Record, + options?: { clientId?: number } + ): Promise => { + notifications.push({ method, params, clientId: options?.clientId }) + } + ), _notifications: notifications, callRequest( method: string, params: Record = {}, - context?: { isStale: () => boolean } + context?: { clientId?: number; isStale: () => boolean } ) { const handler = requestHandlers.get(method) if (!handler) { throw new Error(`No handler for ${method}`) } - return handler(params, context) + return handler( + params, + context ? { clientId: context.clientId ?? 1, isStale: context.isStale } : undefined + ) }, - callNotification(method: string, params: Record = {}) { + callNotification(method: string, params: Record = {}, clientId = 1) { const handler = notificationHandlers.get(method) if (!handler) { throw new Error(`No handler for ${method}`) } - handler(params) + handler(params, { clientId, isStale: () => false }) } } } @@ -100,6 +128,16 @@ async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { let dispatcher: ReturnType let handler: FsHandler @@ -116,19 +154,39 @@ describe('FsHandler readFileStream', () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) + it('rejects a terminal-artifact raster dimension bomb', async () => { + const filePath = path.join(tmpDir, 'artifact-bomb.png') + writeFileSync(filePath, pngContent(24, 32_769, 1)) + + await expect( + dispatcher.callRequest('fs.readTerminalArtifact', { + filePath, + expectedRealPath: await fs.realpath(filePath), + maxBytes: 512 * 1024 + }) + ).rejects.toThrow('Image dimensions exceed the preview safety limit') + }) + it('streams a binary file in chunked notifications', async () => { const filePath = path.join(tmpDir, 'image.png') - const content = Buffer.alloc(300 * 1024, 0x42) + const content = pngContent(300 * 1024) + const ownerClientId = 7 writeFileSync(filePath, content) const meta = (await dispatcher.callRequest( 'fs.readFileStream', { filePath }, - { isStale: () => false } - )) as { streamId: number; totalSize: number; resultEncoding: string } + { clientId: ownerClientId, isStale: () => false } + )) as { + streamId: number + totalSize: number + resultEncoding: string + imageDimensions: { width: number; height: number } + } expect(meta.streamId).toBeDefined() expect(meta.totalSize).toBe(content.length) expect(meta.resultEncoding).toBe('base64') + expect(meta.imageDimensions).toEqual({ width: 1, height: 1 }) await waitFor(() => collectStream(dispatcher).end !== null) const { chunks, end, err } = collectStream(dispatcher) @@ -136,11 +194,48 @@ describe('FsHandler readFileStream', () => { expect(end).toEqual({ streamId: meta.streamId }) const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) expect(reassembled.equals(content)).toBe(true) + expect( + dispatcher._notifications.filter((notification) => notification.method === 'fs.streamEnd') + ).toEqual([ + { + method: 'fs.streamEnd', + params: { streamId: meta.streamId }, + clientId: ownerClientId + } + ]) + }) + + it('sends a stream error only to the requesting relay client', async () => { + const filePath = path.join(tmpDir, 'truncated.png') + const ownerClientId = 9 + writeFileSync(filePath, pngContent(STREAM_CHUNK_SIZE)) + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { clientId: ownerClientId, isStale: () => false } + )) as { streamId: number } + truncateSync(filePath, 0) + + await waitFor(() => collectStream(dispatcher).err !== null) + expect( + dispatcher._notifications.filter((notification) => notification.method === 'fs.streamError') + ).toEqual([ + { + method: 'fs.streamError', + params: { + streamId: meta.streamId, + code: 'ESTREAMTRUNCATED', + message: `File truncated mid-stream: expected ${STREAM_CHUNK_SIZE}, got 0` + }, + clientId: ownerClientId + } + ]) }) it('fills protocol chunks when fs.read returns short before EOF', async () => { const filePath = path.join(tmpDir, 'short-read.png') - const content = Buffer.alloc(STREAM_CHUNK_SIZE + 17, 0x42) + const content = pngContent(STREAM_CHUNK_SIZE + 17) writeFileSync(filePath, content) const sampleHandle = await fs.open(filePath, 'r') @@ -242,7 +337,7 @@ describe('FsHandler readFileStream', () => { it('exits the pump and emits no further chunks when isStale flips', async () => { const filePath = path.join(tmpDir, 'big.png') - const content = Buffer.alloc(800 * 1024, 0x42) + const content = pngContent(800 * 1024) writeFileSync(filePath, content) let stale = false @@ -264,7 +359,7 @@ describe('FsHandler readFileStream', () => { it('honors fs.cancelStream by stopping the pump and emitting no end frame', async () => { const filePath = path.join(tmpDir, 'cancel.png') - writeFileSync(filePath, Buffer.alloc(2 * 1024 * 1024, 0x42)) + writeFileSync(filePath, pngContent(2 * 1024 * 1024)) const meta = (await dispatcher.callRequest( 'fs.readFileStream', @@ -283,7 +378,7 @@ describe('FsHandler readFileStream', () => { it('parks the pump at the ack credit window and resumes on fs.streamAck', async () => { const filePath = path.join(tmpDir, 'paced.png') - const content = Buffer.alloc(1536 * 1024, 0x42) // 6 chunks + const content = pngContent(1536 * 1024) // 6 chunks writeFileSync(filePath, content) const meta = (await dispatcher.callRequest( @@ -298,6 +393,11 @@ describe('FsHandler readFileStream', () => { expect(collectStream(dispatcher).chunks.length).toBe(4) expect(collectStream(dispatcher).end).toBeNull() + // A future ACK must not mint credit for chunks the relay has not sent. + dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 10_000 }) + await flush(10) + expect(collectStream(dispatcher).chunks.length).toBe(4) + // Ack one chunk → exactly one more is admitted. dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 }) await waitFor(() => collectStream(dispatcher).chunks.length === 5) @@ -315,9 +415,38 @@ describe('FsHandler readFileStream', () => { expect(reassembled.equals(content)).toBe(true) }) + it('ignores stream credit and cancellation from a different relay client', async () => { + const filePath = path.join(tmpDir, 'owned-paced.png') + writeFileSync(filePath, pngContent(4 * 1024 * 1024)) + const ownerClientId = 7 + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath, flowControl: 'ack' }, + { clientId: ownerClientId, isStale: () => false } + )) as { streamId: number } + + await waitFor(() => collectStream(dispatcher).chunks.length === 4) + dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 10_000 }, 8) + dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId }, 8) + await flush(10) + expect(collectStream(dispatcher).chunks).toHaveLength(4) + + dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 }, ownerClientId) + await waitFor(() => collectStream(dispatcher).chunks.length === 5) + dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId }, ownerClientId) + + const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry + await waitFor(() => registry.size() === 0) + expect( + dispatcher._notifications + .filter((notification) => notification.method.startsWith('fs.stream')) + .every((notification) => notification.clientId === ownerClientId) + ).toBe(true) + }) + it('releases a pump parked on the ack window when the stream is cancelled', async () => { const filePath = path.join(tmpDir, 'parked-cancel.png') - writeFileSync(filePath, Buffer.alloc(4 * 1024 * 1024, 0x42)) // 16 chunks + writeFileSync(filePath, pngContent(4 * 1024 * 1024)) // 16 chunks const meta = (await dispatcher.callRequest( 'fs.readFileStream', @@ -340,7 +469,7 @@ describe('FsHandler readFileStream', () => { const paths: string[] = [] for (let i = 0; i < 17; i++) { const p = path.join(tmpDir, `s${i}.png`) - writeFileSync(p, Buffer.alloc(8 * 1024 * 1024, 0x42)) + writeFileSync(p, pngContent(8 * 1024 * 1024)) paths.push(p) } @@ -370,4 +499,15 @@ describe('FsHandler readFileStream', () => { } await flush(50) }, 20_000) + + it('rejects an oversized raster before registering or pumping a stream', async () => { + const filePath = path.join(tmpDir, 'bomb.png') + writeFileSync(filePath, pngContent(24, 32_769, 1)) + + await expect( + dispatcher.callRequest('fs.readFileStream', { filePath }, { isStale: () => false }) + ).rejects.toThrow('Image dimensions exceed the preview safety limit') + await flush() + expect(collectStream(dispatcher).chunks).toHaveLength(0) + }) }) diff --git a/src/relay/fs-handler-terminal-artifact.ts b/src/relay/fs-handler-terminal-artifact.ts index 02af753f134..2f5829ad0b7 100644 --- a/src/relay/fs-handler-terminal-artifact.ts +++ b/src/relay/fs-handler-terminal-artifact.ts @@ -10,6 +10,7 @@ import { MAX_PREVIEWABLE_BINARY_SIZE, MAX_TEXT_FILE_SIZE } from './fs-handler-utils' +import { assertRasterImagePreviewWithinLimits } from '../shared/raster-image-preview-limits' type TerminalArtifactStat = { size: number @@ -43,7 +44,14 @@ export async function readVerifiedTerminalArtifact(params: Record { const rgArgs = buildRgArgs(query, rootPath, opts) const acc = createAccumulator() - let buffer = '' + const stdoutLines = new SearchSubprocessLineAccumulator() let resolved = false // Why: spawn can throw synchronously on invalid options (e.g. bad cwd), @@ -134,12 +135,11 @@ export function searchWithRg( } } - function handleStdoutData(chunk: string): void { - buffer += chunk - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) + function handleStdoutData(chunk: Buffer): void { + if (!stdoutLines.push(chunk, processLine)) { + acc.truncated = true + child.kill() + resolveOnce() } } @@ -152,13 +152,13 @@ export function searchWithRg( } function handleClose(): void { - if (buffer) { - processLine(buffer) + const trailingLine = stdoutLines.finish() + if (trailingLine !== null) { + processLine(trailingLine) } resolveOnce() } - child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 23dd28c35cb..3c8bbd847b9 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -17,6 +17,8 @@ vi.mock('@parcel/watcher', () => ({ subscribe: mockSubscribe })) +const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', 'base64') + function createMockDispatcher() { const requestHandlers = new Map< string, @@ -138,6 +140,7 @@ describe('FsHandler', () => { it('registers all expected handlers', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('fs.readDir') + expect(methods).toContain('fs.readDirBounded') expect(methods).toContain('fs.readFile') expect(methods).toContain('fs.tempDir') expect(methods).toContain('fs.writeFile') @@ -152,6 +155,7 @@ describe('FsHandler', () => { expect(methods).toContain('fs.realpath') expect(methods).toContain('fs.search') expect(methods).toContain('fs.listFiles') + expect(methods).toContain('fs.listMarkdownDocuments') expect(methods).toContain('fs.workspaceSpaceScan') expect(methods).toContain('fs.watch') expect(methods).toContain('fs.unwatchAndWait') @@ -164,12 +168,12 @@ describe('FsHandler', () => { await expect(dispatcher.callRequest('fs.tempDir')).resolves.toBe(tmpdir()) }) - it('readDir returns sorted entries with directories first', async () => { + it('bounded readDir returns sorted entries with directories first', async () => { mkdirSync(path.join(tmpDir, 'subdir')) writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') writeFileSync(path.join(tmpDir, 'aaa.txt'), 'world') - const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as { + const result = (await dispatcher.callRequest('fs.readDirBounded', { dirPath: tmpDir })) as { name: string isDirectory: boolean }[] @@ -239,17 +243,19 @@ describe('FsHandler', () => { it('readFile returns base64 for image files', async () => { const filePath = path.join(tmpDir, 'test.png') - writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])) + writeFileSync(filePath, PNG_1X1) const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as { content: string isBinary: boolean isImage: boolean mimeType: string + imageDimensions: { width: number; height: number } } expect(result.isBinary).toBe(true) expect(result.isImage).toBe(true) expect(result.mimeType).toBe('image/png') + expect(result.imageDimensions).toEqual({ width: 1, height: 1 }) expect(result.content).toBeTruthy() }) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 9c146af1450..c741d8f9e99 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,9 +1,8 @@ /* eslint-disable max-lines -- Why: relay filesystem request handling shares path expansion, file IO, search, streaming reads, and Space scans. */ -import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs/promises' +import { writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs/promises' import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' -import { join } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat @@ -36,24 +35,27 @@ import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' +import { + MOBILE_FILE_DIRECTORY_MAX_ENTRIES, + MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES +} from '../shared/mobile-file-directory-limit' +import { readMobileRelayDirectory } from './mobile-file-directory-reader' +import { readRelayFilesystemDirectory } from './filesystem-directory-reader' +import type { FilesystemDirectoryListingLimits } from '../shared/filesystem-directory-listing-limit' +import { listRelayMarkdownDocumentPaths } from './markdown-document-listing' +import { resolveQuickOpenResultLimit } from '../shared/quick-open-listing-limits' -async function isDirectoryEntry( - dirPath: string, - entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean } -): Promise { - if (entry.isDirectory()) { - return true +function readDirectoryLimitsFromParams( + params: Record +): Partial { + const limits: Partial = {} + if (typeof params.maxEntries === 'number') { + limits.maxEntries = params.maxEntries } - if (!entry.isSymbolicLink()) { - return false - } - try { - // Why: the file explorer needs target type for symlinked directories so a - // workspace link to an external folder expands instead of opening as a file. - return (await stat(join(dirPath, entry.name))).isDirectory() - } catch { - return false + if (typeof params.maxRetainedBytes === 'number') { + limits.maxRetainedBytes = params.maxRetainedBytes } + return limits } function fileStatFromLstat(stats: Awaited>) { @@ -102,6 +104,7 @@ export class FsHandler { private registerHandlers(): void { this.dispatcher.onRequest('fs.readDir', (p) => this.readDir(p)) + this.dispatcher.onRequest('fs.readDirBounded', (p) => this.readDir(p)) this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) this.dispatcher.onRequest('fs.readFileStream', (p, c) => this.readFileStream(p, c)) this.dispatcher.onRequest('fs.readTerminalArtifact', (p) => this.readTerminalArtifact(p)) @@ -120,6 +123,9 @@ export class FsHandler { this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) + this.dispatcher.onRequest('fs.listMarkdownDocuments', (p, c) => + listRelayMarkdownDocumentPaths(expandTilde(p.rootPath as string), c.signal) + ) this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) this.dispatcher.onRequest('fs.watch', (p, context) => this.watchRegistry.watch( @@ -134,21 +140,25 @@ export class FsHandler { this.dispatcher.onNotification('fs.unwatch', (p, context) => this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context) ) - this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) - this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p)) + this.dispatcher.onNotification('fs.cancelStream', (p, context) => this.cancelStream(p, context)) + this.dispatcher.onNotification('fs.streamAck', (p, context) => this.streamAck(p, context)) } private async readDir(params: Record) { const dirPath = expandTilde(params.dirPath as string) - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = await Promise.all( - entries.map(async (entry) => ({ - name: entry.name, - isDirectory: await isDirectoryEntry(dirPath, entry), - isSymlink: entry.isSymbolicLink() - })) - ) - return mapped.sort((a, b) => { + if ( + params.maxEntries === MOBILE_FILE_DIRECTORY_MAX_ENTRIES && + params.maxRetainedBytes === MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES + ) { + return this.sortDirectoryEntries(await readMobileRelayDirectory(dirPath)) + } + return readRelayFilesystemDirectory(dirPath, readDirectoryLimitsFromParams(params)) + } + + private sortDirectoryEntries( + entries: T[] + ): T[] { + return entries.sort((a, b) => { if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1 } @@ -184,18 +194,18 @@ export class FsHandler { return tmpdir() } - private cancelStream(params: Record): void { + private cancelStream(params: Record, context: RequestContext): void { const streamId = params.streamId as number | undefined if (typeof streamId === 'number') { - this.streamRegistry.abort(streamId) + this.streamRegistry.abort(streamId, context.clientId) } } - private streamAck(params: Record): void { + private streamAck(params: Record, context: RequestContext): void { const streamId = params.streamId as number | undefined const seq = params.seq as number | undefined if (typeof streamId === 'number' && typeof seq === 'number') { - this.streamRegistry.recordAck(streamId, seq) + this.streamRegistry.recordAck(streamId, seq, context.clientId) } } @@ -356,12 +366,13 @@ export class FsHandler { private listFiles(params: Record, context?: RequestContext): Promise { const rootPath = expandTilde(params.rootPath as string) - const maxResults = + const requestedMaxResults = typeof params.maxResults === 'number' && Number.isInteger(params.maxResults) && params.maxResults > 0 - ? Math.min(params.maxResults, 20_001) + ? params.maxResults : undefined + const maxResults = resolveQuickOpenResultLimit(requestedMaxResults) // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so @@ -382,7 +393,7 @@ export class FsHandler { rootPath: string, excludePathPrefixes: string[], signal: AbortSignal, - maxResults?: number + maxResults: number ): Promise { const rgAvailable = await checkRgAvailable() throwIfFileListingCancelled(signal) diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts index 588ddb5fe51..4f9118e608d 100644 --- a/src/relay/fs-list-files-cancel.integration.test.ts +++ b/src/relay/fs-list-files-cancel.integration.test.ts @@ -27,7 +27,7 @@ const { fakeListFiles } = vi.hoisted(() => { ( rootPath: string, _excludes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ) => new Promise((resolve, reject) => { scans.push({ rootPath, signal: options.signal, resolve }) @@ -67,6 +67,7 @@ import { RelayDispatcher } from './dispatcher' import { RelayContext } from './context' import { FsHandler } from './fs-handler' import { LIST_FILES_SUPERSEDED_MESSAGE } from './fs-list-files-scan-coordinator' +import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits' async function flushPipe(): Promise { // The in-memory pipe defers each hop with setImmediate; a few macrotask @@ -123,6 +124,7 @@ describe('Integration: cancellable fs.listFiles (#7721)', () => { const scanPromise = mux.request('fs.listFiles', { rootPath: '/big/workspace' }) await flushPipe() expect(fakeListFiles.scans).toHaveLength(1) + expect(fakeListFiles.mock.calls[0][2]?.maxResults).toBe(QUICK_OPEN_LISTING_MAX_RESULTS) // The interactive request must complete while the scan is still pending. const entries = (await mux.request('fs.readDir', { dirPath: tmpDir })) as { name: string }[] diff --git a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts index 8cca2787891..3875d22a282 100644 --- a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts +++ b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts @@ -37,6 +37,16 @@ const FRAMED_CHUNK_BYTES = Math.ceil((STREAM_CHUNK_SIZE * 4) / 3) + 512 // Node pipe/socket sinks report saturation via write() === false past the HWM. const SINK_HIGH_WATER_MARK = 64 * 1024 +function randomPngBytes(size: number): Buffer { + const content = randomBytes(size) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(content) + content.writeUInt32BE(13, 8) + content.write('IHDR', 12, 'ascii') + content.writeUInt32BE(1, 16) + content.writeUInt32BE(1, 20) + return content +} + async function waitUntil( predicate: () => boolean, what: string, @@ -182,7 +192,7 @@ describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { const harness = createHarness({ congested: true }) try { const filePath = path.join(tmpDir, 'big.png') - const original = randomBytes(3 * 1024 * 1024) // 12 chunks + const original = randomPngBytes(3 * 1024 * 1024) // 12 chunks writeFileSync(filePath, original) // Relay-side fake PTY: echoes input back immediately, mirroring @@ -222,11 +232,17 @@ describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { const harness = createHarness({ congested: false }) try { const filePath = path.join(tmpDir, 'big.png') - writeFileSync(filePath, randomBytes(3 * 1024 * 1024)) // 12 chunks + writeFileSync(filePath, randomPngBytes(3 * 1024 * 1024)) // 12 chunks const receivedSeqs: number[] = [] + let acknowledgeChunks = false + let streamId: number | null = null harness.mux.onNotificationByMethod('fs.streamChunk', (params) => { - receivedSeqs.push(params.seq as number) + const seq = params.seq as number + receivedSeqs.push(seq) + if (acknowledgeChunks && streamId !== null) { + harness.mux.notify('fs.streamAck', { streamId, seq }) + } }) let streamEnded = false harness.mux.onNotificationByMethod('fs.streamEnd', () => { @@ -240,6 +256,7 @@ describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { filePath, flowControl: 'ack' })) as { streamId: number } + streamId = metadata.streamId await waitUntil(() => receivedSeqs.length > 0, 'first chunk received') await waitUntilSettled(() => receivedSeqs.length) @@ -250,8 +267,9 @@ describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { expect(streamEnded).toBe(false) // Acking releases the window and the stream completes. + acknowledgeChunks = true const totalChunks = 12 - for (let seq = 0; seq < totalChunks; seq += 1) { + for (const seq of receivedSeqs) { harness.mux.notify('fs.streamAck', { streamId: metadata.streamId, seq }) } await waitUntil(() => streamEnded, 'stream completed after acks') @@ -265,7 +283,7 @@ describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { const harness = createHarness({ congested: false }) try { const filePath = path.join(tmpDir, 'legacy.png') - const original = randomBytes(1024 * 1024 + 12345) + const original = randomPngBytes(1024 * 1024 + 12345) writeFileSync(filePath, original) const chunks = new Map() diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts index 2bca76c9495..09b687aa714 100644 --- a/src/relay/fs-stream-registry.ts +++ b/src/relay/fs-stream-registry.ts @@ -3,7 +3,10 @@ import { MAX_CONCURRENT_STREAMS, RelayErrorCode, STREAM_ACK_STALL_RECHECK_MS } f type StreamEntry = { handle: FileHandle + ownerClientId: number aborted: boolean + /** Highest chunk seq admitted to the outbound bulk lane. */ + sentThroughSeq: number /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */ ackedThroughSeq: number /** Pumps parked on the ack credit window. Woken by acks, abort, release, @@ -22,23 +25,25 @@ export class RelayStreamRegistry { private streams = new Map() private nextId = 1 - register(handle: FileHandle): number { + register(handle: FileHandle, ownerClientId: number): number { if (this.streams.size >= MAX_CONCURRENT_STREAMS) { throw new TooManyStreamsError() } const streamId = this.nextId++ this.streams.set(streamId, { handle, + ownerClientId, aborted: false, + sentThroughSeq: -1, ackedThroughSeq: -1, ackWaiters: new Set() }) return streamId } - abort(streamId: number): void { + abort(streamId: number, clientId: number): void { const entry = this.streams.get(streamId) - if (entry) { + if (entry?.ownerClientId === clientId) { entry.aborted = true this.wakeAckWaiters(entry) } @@ -52,9 +57,22 @@ export class RelayStreamRegistry { return this.streams.get(streamId) } - recordAck(streamId: number, seq: number): void { + recordSent(streamId: number, seq: number): void { const entry = this.streams.get(streamId) - if (!entry || typeof seq !== 'number' || !Number.isFinite(seq)) { + if (entry && Number.isSafeInteger(seq) && seq === entry.sentThroughSeq + 1) { + entry.sentThroughSeq = seq + } + } + + recordAck(streamId: number, seq: number, clientId: number): void { + const entry = this.streams.get(streamId) + if ( + !entry || + entry.ownerClientId !== clientId || + !Number.isSafeInteger(seq) || + seq < 0 || + seq > entry.sentThroughSeq + ) { return } if (seq > entry.ackedThroughSeq) { @@ -129,7 +147,11 @@ export class RelayStreamRegistry { // cleanly on the next iteration boundary instead of seeing EBADF when // release closes the handle out from under an in-flight read. for (const id of this.streams.keys()) { - this.abort(id) + const entry = this.streams.get(id) + if (entry) { + entry.aborted = true + this.wakeAckWaiters(entry) + } } const ids = Array.from(this.streams.keys()) await Promise.all(ids.map((id) => this.release(id))) diff --git a/src/relay/git-handler-status-ops-bounds.test.ts b/src/relay/git-handler-status-ops-bounds.test.ts new file mode 100644 index 00000000000..8849ad9b334 --- /dev/null +++ b/src/relay/git-handler-status-ops-bounds.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resolveGitDir } from './git-handler-status-ops' + +describe('relay resolveGitDir metadata bounds', () => { + const roots: string[] = [] + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + + async function makeWorktree(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-resolve-git-dir-')) + roots.push(root) + const worktreePath = join(root, 'checkout') + await mkdir(worktreePath) + return worktreePath + } + + it('preserves a normal linked-worktree pointer', async () => { + const worktreePath = await makeWorktree() + await writeFile(join(worktreePath, '.git'), 'gitdir: ../common/.git/worktrees/checkout\n') + + await expect(resolveGitDir(worktreePath)).resolves.toBe( + join(worktreePath, '..', 'common', '.git', 'worktrees', 'checkout') + ) + }) + + it('falls back to the .git path for an oversized sparse pointer', async () => { + const worktreePath = await makeWorktree() + const dotGitPath = join(worktreePath, '.git') + await writeFile(dotGitPath, 'x') + await truncate(dotGitPath, 64 * 1024 + 1) + + await expect(resolveGitDir(worktreePath)).resolves.toBe(dotGitPath) + }) +}) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 1bd05821f50..df2522173bf 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -4,7 +4,6 @@ */ import * as path from 'node:path' import { existsSync } from 'node:fs' -import { readFile } from 'node:fs/promises' import { parseUnmergedEntry } from './git-handler-utils' import type { GitExec } from './git-handler-ops' import type { RelayGitStreamExec } from './git-stdout-stream' @@ -24,11 +23,16 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../shared/git-status-line-stats-cache' +import { readNodeFileWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' + +const MAX_GIT_POINTER_FILE_BYTES = 64 * 1024 export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') try { - const contents = await readFile(dotGitPath, 'utf-8') + const contents = ( + await readNodeFileWithinLimit(dotGitPath, MAX_GIT_POINTER_FILE_BYTES) + ).buffer.toString('utf-8') const match = contents.match(/^gitdir:\s*(.+)\s*$/m) if (match) { return path.resolve(worktreePath, match[1]) diff --git a/src/relay/git-handler-stream-serialization-memory.test.ts b/src/relay/git-handler-stream-serialization-memory.test.ts new file mode 100644 index 00000000000..0fae5409820 --- /dev/null +++ b/src/relay/git-handler-stream-serialization-memory.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' +import { RelayContext } from './context' +import { RelayDispatcher, type RequestContext } from './dispatcher' +import { GitHandler } from './git-handler' +import { GIT_RESPONSE_STREAM_THRESHOLD, type GitResponseStreamMarker } from './protocol' + +type StreamableGitHandler = { + maybeStreamResponse( + result: unknown, + params: Record, + context: RequestContext + ): GitResponseStreamMarker +} + +describe('GitHandler streamed response serialization', () => { + it('registers a large serialized string without allocating a full payload Buffer', () => { + const dispatcher = new RelayDispatcher(() => true) + const handler = new GitHandler(dispatcher, new RelayContext()) + const streamable = handler as unknown as StreamableGitHandler + const result = { text: 'x'.repeat(GIT_RESPONSE_STREAM_THRESHOLD + 1) } + const fromSpy = vi.spyOn(Buffer, 'from') + + try { + const marker = streamable.maybeStreamResponse( + result, + { __streamResponse: true }, + { clientId: 1, isStale: () => false } + ) + + expect(marker.__orcaGitResponseStream.totalBytes).toBe( + Buffer.byteLength(JSON.stringify(result), 'utf8') + ) + expect(fromSpy).not.toHaveBeenCalled() + } finally { + handler.dispose() + dispatcher.dispose() + fromSpy.mockRestore() + } + }) +}) diff --git a/src/relay/git-handler-submodule-ops.test.ts b/src/relay/git-handler-submodule-ops.test.ts index 15481097688..937d5d19850 100644 --- a/src/relay/git-handler-submodule-ops.test.ts +++ b/src/relay/git-handler-submodule-ops.test.ts @@ -2,11 +2,19 @@ import { describe, expect, it } from 'vitest' import * as path from 'node:path' import type { GitExec } from './git-handler-ops' import { + MAX_SUBMODULE_PATH_CODE_UNITS, + MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS, MAX_SUBMODULE_PATHS_CACHE_ENTRIES, + MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES, + MAX_SUBMODULE_PATHS_OUTPUT_BYTES, + MAX_SUBMODULE_PATHS_PER_REPO, + MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS, SUBMODULE_PATHS_CACHE_TTL_MS, clearSubmodulePathsCache, createSubmodulePathsCache, + getSubmodulePathsCacheCodeUnits, getSubmodulePathsCacheCount, + listSubmodulePaths, listSubmodulePathsCached, resolveSubmoduleWorktreePath } from './git-handler-submodule-ops' @@ -26,6 +34,19 @@ function gitmodulesExec(paths: string[]): { git: GitExec; calls: () => number } return { git, calls: () => calls } } +function pathsUsingCodeUnits(codeUnits: number): string[] { + const fullPath = 'x'.repeat(MAX_SUBMODULE_PATH_CODE_UNITS) + const paths = Array.from( + { length: Math.floor(codeUnits / MAX_SUBMODULE_PATH_CODE_UNITS) }, + () => fullPath + ) + const remainder = codeUnits % MAX_SUBMODULE_PATH_CODE_UNITS + if (remainder > 0) { + paths.push('x'.repeat(remainder)) + } + return paths +} + describe('listSubmodulePathsCached', () => { it('reads .gitmodules once for repeated diffs on the same worktree within TTL', async () => { const { git, calls } = gitmodulesExec(['vendor/lib']) @@ -39,6 +60,19 @@ describe('listSubmodulePathsCached', () => { expect(calls()).toBe(1) }) + it('keeps the existing 10 MiB command-output ceiling explicit', async () => { + let receivedOptions: Parameters[2] + const git: GitExec = async (_args, _cwd, options) => { + receivedOptions = options + return { stdout: 'submodule.lib.path vendor/lib\n', stderr: '' } + } + + await listSubmodulePaths(git, '/repo') + + expect(receivedOptions).toEqual({ maxBuffer: MAX_SUBMODULE_PATHS_OUTPUT_BYTES }) + expect(MAX_SUBMODULE_PATHS_OUTPUT_BYTES).toBe(10 * 1024 * 1024) + }) + it('re-reads after the TTL expires', async () => { const { git, calls } = gitmodulesExec(['vendor/lib']) const cache = createSubmodulePathsCache() @@ -69,6 +103,7 @@ describe('listSubmodulePathsCached', () => { await listSubmodulePathsCached(git, '/repo-c', cache, SUBMODULE_PATHS_CACHE_TTL_MS + 1) expect(getSubmodulePathsCacheCount(cache)).toBe(1) + expect(getSubmodulePathsCacheCodeUnits(cache)).toBe('/repo-c'.length + 'vendor/lib'.length) }) it('caches an empty result so a submodule-free repo is not re-read', async () => { @@ -121,6 +156,69 @@ describe('listSubmodulePathsCached', () => { expect(calls()).toBe(callsBeforeReads + 1) }) + it('does not retain worktree keys above the UTF-8 byte ceiling', async () => { + const exact = gitmodulesExec(['vendor/lib']) + const exactCache = createSubmodulePathsCache() + const exactKey = 'r'.repeat(MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES) + + await listSubmodulePathsCached(exact.git, exactKey, exactCache, 1_000) + await listSubmodulePathsCached(exact.git, exactKey, exactCache, 1_001) + + expect(exact.calls()).toBe(1) + expect(getSubmodulePathsCacheCount(exactCache)).toBe(1) + + const overflow = gitmodulesExec(['vendor/lib']) + const overflowCache = createSubmodulePathsCache() + const overflowKey = '界'.repeat(Math.floor(MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES / 3) + 1) + expect(Buffer.byteLength(overflowKey, 'utf8')).toBeGreaterThan( + MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES + ) + + await listSubmodulePathsCached(overflow.git, overflowKey, overflowCache, 1_000) + await listSubmodulePathsCached(overflow.git, overflowKey, overflowCache, 1_001) + + expect(overflow.calls()).toBe(2) + expect(getSubmodulePathsCacheCount(overflowCache)).toBe(0) + expect(getSubmodulePathsCacheCodeUnits(overflowCache)).toBe(0) + }) + + it('evicts LRU payloads above the global retained-code-unit ceiling and recovers', async () => { + const calls = new Map() + const git: GitExec = async (_args, cwd) => { + calls.set(cwd, (calls.get(cwd) ?? 0) + 1) + const paths = + cwd === '/overflow' + ? ['vendor/lib'] + : pathsUsingCodeUnits(MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS - cwd.length) + return { + stdout: paths + .map((submodulePath, i) => `submodule.sub${i}.path ${submodulePath}`) + .join('\n'), + stderr: '' + } + } + const cache = createSubmodulePathsCache() + + for (let i = 0; i < 4; i += 1) { + await listSubmodulePathsCached(git, `/repo-${i}`, cache, 1_000) + } + expect(getSubmodulePathsCacheCodeUnits(cache)).toBe(MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS) + + await listSubmodulePathsCached(git, '/overflow', cache, 1_000) + + expect(getSubmodulePathsCacheCount(cache)).toBe(4) + expect(getSubmodulePathsCacheCodeUnits(cache)).toBeLessThan( + MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS + ) + await listSubmodulePathsCached(git, '/repo-1', cache, 1_001) + expect(calls.get('/repo-1')).toBe(1) + await listSubmodulePathsCached(git, '/repo-0', cache, 1_001) + expect(calls.get('/repo-0')).toBe(2) + expect(getSubmodulePathsCacheCodeUnits(cache)).toBeLessThanOrEqual( + MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS + ) + }) + it('does not let a pre-mutation SSH read repopulate the cache', async () => { let resolveOldRead: ((value: { stdout: string; stderr: string }) => void) | undefined let calls = 0 @@ -138,6 +236,7 @@ describe('listSubmodulePathsCached', () => { const oldRead = listSubmodulePathsCached(git, '/repo', cache, 1_000) expect(resolveOldRead).toBeTypeOf('function') clearSubmodulePathsCache(cache) + expect(getSubmodulePathsCacheCodeUnits(cache)).toBe(0) resolveOldRead?.({ stdout: 'submodule.lib.path old-lib\n', stderr: '' }) await expect(oldRead).resolves.toEqual(['old-lib']) @@ -149,6 +248,46 @@ describe('listSubmodulePathsCached', () => { }) }) +describe('listSubmodulePaths', () => { + it('preserves the exact path-count boundary and rejects one more', async () => { + const exactPaths = Array.from( + { length: MAX_SUBMODULE_PATHS_PER_REPO }, + (_, index) => `vendor/lib-${index}` + ) + const exact = await listSubmodulePaths(gitmodulesExec(exactPaths).git, '/repo') + const overflow = await listSubmodulePaths( + gitmodulesExec([...exactPaths, 'vendor/overflow']).git, + '/repo' + ) + + expect(exact).toHaveLength(MAX_SUBMODULE_PATHS_PER_REPO) + expect(exact.at(-1)).toBe(`vendor/lib-${MAX_SUBMODULE_PATHS_PER_REPO - 1}`) + expect(overflow).toEqual([]) + }) + + it('preserves the exact per-path boundary and rejects one more code unit', async () => { + const exactPath = 'x'.repeat(MAX_SUBMODULE_PATH_CODE_UNITS) + + await expect(listSubmodulePaths(gitmodulesExec([exactPath]).git, '/repo')).resolves.toEqual([ + exactPath + ]) + await expect( + listSubmodulePaths(gitmodulesExec([`${exactPath}x`]).git, '/repo') + ).resolves.toEqual([]) + }) + + it('preserves the exact per-repo payload boundary and rejects one more code unit', async () => { + const exactPaths = pathsUsingCodeUnits(MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS) + const exact = await listSubmodulePaths(gitmodulesExec(exactPaths).git, '/repo') + const overflow = await listSubmodulePaths(gitmodulesExec([...exactPaths, 'x']).git, '/repo') + + expect(exact.reduce((total, submodulePath) => total + submodulePath.length, 0)).toBe( + MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS + ) + expect(overflow).toEqual([]) + }) +}) + describe('resolveSubmoduleWorktreePath', () => { it('resolves relative submodule paths inside the selected worktree', () => { expect(resolveSubmoduleWorktreePath('/repo', 'vendor/lib')).toBe( diff --git a/src/relay/git-handler-submodule-ops.ts b/src/relay/git-handler-submodule-ops.ts index 0409ba61177..d0f5ed595f8 100644 --- a/src/relay/git-handler-submodule-ops.ts +++ b/src/relay/git-handler-submodule-ops.ts @@ -11,6 +11,7 @@ import * as path from 'node:path' import { buildDiffResult } from './git-diff-result' import { parseBranchDiff } from './git-handler-utils' import { parseNumstat } from '../shared/git-uncommitted-line-stats' +import { iterateProcessOutputLines } from '../shared/process-output-field-scanner' import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops' /** @@ -20,18 +21,26 @@ import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-o */ export const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 -type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } +export const MAX_SUBMODULE_PATHS_OUTPUT_BYTES = 10 * 1024 * 1024 +export const MAX_SUBMODULE_PATHS_PER_REPO = 10_000 +export const MAX_SUBMODULE_PATH_CODE_UNITS = 64 * 1024 +export const MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS = 4 * 1024 * 1024 +export const MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS = 16 * 1024 * 1024 +export const MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES = 64 * 1024 +type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number; retainedCodeUnits: number } export type SubmodulePathsCache = { entries: Map generation: number + retainedCodeUnits: number } export function createSubmodulePathsCache(): SubmodulePathsCache { - return { entries: new Map(), generation: 0 } + return { entries: new Map(), generation: 0, retainedCodeUnits: 0 } } export function clearSubmodulePathsCache(cache: SubmodulePathsCache): void { cache.entries.clear() + cache.retainedCodeUnits = 0 // Why: a pre-mutation SSH read must not restore stale .gitmodules paths // after the mutation invalidated them. cache.generation += 1 @@ -41,6 +50,19 @@ export function getSubmodulePathsCacheCount(cache: SubmodulePathsCache): number return cache.entries.size } +export function getSubmodulePathsCacheCodeUnits(cache: SubmodulePathsCache): number { + return cache.retainedCodeUnits +} + +function deleteSubmodulePathsCacheEntry(cache: SubmodulePathsCache, worktreePath: string): void { + const entry = cache.entries.get(worktreePath) + if (!entry) { + return + } + cache.entries.delete(worktreePath) + cache.retainedCodeUnits -= entry.retainedCodeUnits +} + function getCachedSubmodulePaths( cache: SubmodulePathsCache, worktreePath: string, @@ -51,7 +73,7 @@ function getCachedSubmodulePaths( return null } if (cached.expiresAt <= now) { - cache.entries.delete(worktreePath) + deleteSubmodulePathsCacheEntry(cache, worktreePath) return null } cache.entries.delete(worktreePath) @@ -62,7 +84,7 @@ function getCachedSubmodulePaths( function pruneExpiredSubmodulePaths(cache: SubmodulePathsCache, now: number): void { for (const [worktreePath, entry] of cache.entries) { if (entry.expiresAt <= now) { - cache.entries.delete(worktreePath) + deleteSubmodulePathsCacheEntry(cache, worktreePath) } } } @@ -73,14 +95,31 @@ function rememberSubmodulePaths( paths: string[], now: number ): void { - cache.entries.delete(worktreePath) - cache.entries.set(worktreePath, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) - while (cache.entries.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { + deleteSubmodulePathsCacheEntry(cache, worktreePath) + if (Buffer.byteLength(worktreePath, 'utf8') > MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES) { + return + } + // Why: JS strings retain UTF-16 code units, so byte counts understate non-ASCII heap use. + const retainedCodeUnits = + worktreePath.length + paths.reduce((total, submodulePath) => total + submodulePath.length, 0) + if (retainedCodeUnits > MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS) { + return + } + cache.entries.set(worktreePath, { + paths, + expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS, + retainedCodeUnits + }) + cache.retainedCodeUnits += retainedCodeUnits + while ( + cache.entries.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES || + cache.retainedCodeUnits > MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS + ) { const oldestPath = cache.entries.keys().next().value if (oldestPath === undefined) { break } - cache.entries.delete(oldestPath) + deleteSubmodulePathsCacheEntry(cache, oldestPath) } } @@ -119,20 +158,37 @@ export async function listSubmodulePaths(git: GitExec, worktreePath: string): Pr try { const { stdout } = await git( ['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], - worktreePath + worktreePath, + { maxBuffer: MAX_SUBMODULE_PATHS_OUTPUT_BYTES } ) - return stdout - .split(/\r?\n/) - .map((line) => { - const spaceIndex = line.indexOf(' ') - return spaceIndex === -1 + const paths: string[] = [] + let retainedCodeUnits = 0 + for (const line of iterateProcessOutputLines(stdout)) { + if (line.length > MAX_SUBMODULE_PATH_CODE_UNITS + 4_096) { + return [] + } + const spaceIndex = line.indexOf(' ') + const submodulePath = + spaceIndex === -1 ? '' : line .slice(spaceIndex + 1) .trim() .replace(/\/+$/, '') - }) - .filter((value) => value.length > 0) + if (!submodulePath) { + continue + } + if ( + paths.length >= MAX_SUBMODULE_PATHS_PER_REPO || + submodulePath.length > MAX_SUBMODULE_PATH_CODE_UNITS || + submodulePath.length > MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS - retainedCodeUnits + ) { + return [] + } + paths.push(submodulePath) + retainedCodeUnits += submodulePath.length + } + return paths } catch { return [] } diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index ab3bb61faba..329797b687e 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -10,6 +10,7 @@ import * as path from 'node:path' import { decodeGitCQuotedPath } from '../shared/git-cquoted-path' import { isBinaryBuffer } from '../shared/binary-buffer' import type { GitLineStats } from '../shared/git-uncommitted-line-stats' +import { iterateNulDelimitedFields } from '../shared/nul-delimited-fields' export { isUnsupportedWorktreeListZError } from '../shared/git-worktree-command-capabilities' export function parseBranchStatusChar(char: string): string { @@ -209,7 +210,7 @@ function splitNulWorktreeList(output: string): string[][] { const blocks: string[][] = [] let currentBlock: string[] = [] - for (const field of output.split('\0')) { + for (const field of iterateNulDelimitedFields(output)) { if (field) { currentBlock.push(field) continue diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 15c4884f1b1..68e1982d4e2 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -8,7 +8,6 @@ import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../shared/large-diff-render-limit' -import { reviewHeadRemoteRefComponent } from '../shared/review-head-tracking-ref' import { createMockDispatcher, gitInit, @@ -119,9 +118,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.fetch') expect(methods).toContain('git.forkSync') expect(methods).toContain('git.fetchRemoteTrackingRef') - expect(methods).toContain('git.fetchGitHubPullRequestHead') expect(methods).toContain('git.fetchGitLabMergeRequestHead') - expect(methods).toContain('git.fetchGitLabMergeRequestHeadRef') expect(methods).toContain('git.push') expect(methods).toContain('git.pull') expect(methods).toContain('git.fastForward') @@ -1816,60 +1813,6 @@ describe('GitHandler', () => { ).rejects.toThrow('Remote-tracking ref does not match the requested remote and branch.') }) - it('fetches GitHub pull request heads through the narrow fetch RPC', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-github-pr-bare-')) - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'pr.txt'), 'head') - gitCommit(tmpDir, 'pr head') - const expected = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['push', 'origin', 'HEAD:refs/pull/42/head'], { - cwd: tmpDir, - stdio: 'pipe' - }) - - const result = (await dispatcher.callRequest('git.fetchGitHubPullRequestHead', { - worktreePath: tmpDir, - remote: 'origin', - prNumber: 42 - })) as { localRef: string } - - // The ref is scoped by remote identity so soft-keep can never serve - // another project's PR #42 out of the same object database. - const component = reviewHeadRemoteRefComponent('origin', bareDir) - expect(result.localRef).toBe(`refs/orca/pull/${component}/42`) - const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(expected) - } finally { - await fs.rm(bareDir, { recursive: true, force: true }) - } - }) - - it('rejects invalid GitHub pull request head fetch requests', async () => { - await expect( - dispatcher.callRequest('git.fetchGitHubPullRequestHead', { - worktreePath: tmpDir, - remote: '-origin', - prNumber: 42 - }) - ).rejects.toThrow('GitHub pull request fetch remote must not start with "-".') - await expect( - dispatcher.callRequest('git.fetchGitHubPullRequestHead', { - worktreePath: tmpDir, - remote: 'origin', - prNumber: 0 - }) - ).rejects.toThrow('Invalid GitHub pull request fetch request.') - }) - it('fetches GitLab merge request heads through the narrow fetch RPC', async () => { const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-gitlab-mr-bare-')) try { @@ -1887,60 +1830,13 @@ describe('GitHandler', () => { stdio: 'pipe' }) - const result = (await dispatcher.callRequest('git.fetchGitLabMergeRequestHead', { + await dispatcher.callRequest('git.fetchGitLabMergeRequestHead', { worktreePath: tmpDir, remote: 'origin', mrIid: 42 - })) as { localRef: string } - - // The head is fetched into a dedicated ref (not shared FETCH_HEAD) so a - // concurrent fetch can't retarget the caller's rev-parse of the checkout. - const component = reviewHeadRemoteRefComponent('origin', bareDir) - expect(result.localRef).toBe(`refs/orca/merge-requests/${component}/42`) - const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(expected) - // Legacy contract: pre-durable-ref desktop clients call this method name - // and then resolve FETCH_HEAD, which a refspec fetch still writes. - const fetchHead = execFileSync('git', ['rev-parse', '--verify', 'FETCH_HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(fetchHead).toBe(expected) - } finally { - await fs.rm(bareDir, { recursive: true, force: true }) - } - }) - - it('fetches GitLab merge request heads through the versioned durable-ref RPC', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-gitlab-mr-ref-bare-')) - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'mr.txt'), 'head') - gitCommit(tmpDir, 'mr head') - const expected = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['push', 'origin', 'HEAD:refs/merge-requests/77/head'], { - cwd: tmpDir, - stdio: 'pipe' }) - // Why: new clients call the versioned name; old relays 404 it and prompt reconnect. - const result = (await dispatcher.callRequest('git.fetchGitLabMergeRequestHeadRef', { - worktreePath: tmpDir, - remote: 'origin', - mrIid: 77 - })) as { localRef: string } - - const component = reviewHeadRemoteRefComponent('origin', bareDir) - expect(result.localRef).toBe(`refs/orca/merge-requests/${component}/77`) - const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], { + const actual = execFileSync('git', ['rev-parse', 'FETCH_HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim() diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index cdc0717b7a9..771cf57bd13 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -11,6 +11,7 @@ import { parseWorktreeList } from './git-handler-utils' import { parseNumstat } from '../shared/git-uncommitted-line-stats' +import { iterateNulDelimitedFields } from '../shared/nul-delimited-fields' import { computeDiff, branchCompare as branchCompareOp, @@ -47,7 +48,6 @@ import { capGitStatusEntries, resolveGitStatusLimit } from '../shared/git-status import { checkIgnoredPathsOp } from './git-handler-check-ignore' import { resolveRelayPushTarget } from './git-handler-push-target' import { - isExecKilledError, isNoUpstreamError, normalizeGitErrorMessage, runPullWithDivergenceFallback @@ -72,21 +72,21 @@ import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../s import { InFlightPromiseDedupe, stableInFlightKey } from '../shared/in-flight-promise-dedupe' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance' import { GitCapabilityCache } from '../shared/git-capability-cache' -import { - githubPullRequestHeadLocalRef, - gitlabMergeRequestHeadLocalRef, - isSafeReviewHeadFetchRemote, - isValidReviewHeadNumber, - reviewHeadRemoteRefComponent, - REVIEW_HEAD_FETCH_TIMEOUT_MS -} from '../shared/review-head-tracking-ref' import type { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import { hasUnsupportedRevParsePathFormatEcho, isUnsupportedRevParsePathFormatError } from '../shared/git-worktree-command-capabilities' import { GitResponseStreamRegistry } from './git-response-stream' -import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' +import { + JsonStringifyByteLimitError, + stringifyJsonWithinByteLimit +} from '../shared/memory-safety/node-bounded-json-stringify' +import { + GIT_RESPONSE_STREAM_THRESHOLD, + MAX_GIT_RESPONSE_STREAM_BYTES, + RelayErrorCode +} from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' import { streamRelayGitStdout } from './git-stdout-stream' @@ -225,20 +225,9 @@ export class GitHandler { this.dispatcher.onRequest('git.fetch', (p) => this.fetch(p)) this.dispatcher.onRequest('git.forkSync', (p, context) => this.forkSync(p, context)) this.dispatcher.onRequest('git.fetchRemoteTrackingRef', (p) => this.fetchRemoteTrackingRef(p)) - this.dispatcher.onRequest('git.fetchGitHubPullRequestHead', (p) => - this.fetchGitHubPullRequestHead(p) - ) this.dispatcher.onRequest('git.fetchGitLabMergeRequestHead', (p) => this.fetchGitLabMergeRequestHead(p) ) - // Why: the durable-ref variant is a distinct method name so an old relay - // (which only knows FETCH_HEAD-semantics git.fetchGitLabMergeRequestHead) - // returns -32601 and the client can prompt a reconnect instead of silently - // resolving a stale/missing ref. Both names share the durable handler: a - // refspec fetch still writes FETCH_HEAD, so old clients keep their semantics. - this.dispatcher.onRequest('git.fetchGitLabMergeRequestHeadRef', (p) => - this.fetchGitLabMergeRequestHead(p) - ) this.dispatcher.onRequest('git.push', (p) => this.push(p)) this.dispatcher.onRequest('git.pull', (p) => this.pull(p)) this.dispatcher.onRequest('git.fastForward', (p) => this.fastForward(p)) @@ -289,11 +278,26 @@ export class GitHandler { if (params.__streamResponse !== true || !context) { return result } - const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8') - if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) { + let serialized: string + let payloadBytes: number + try { + const bounded = stringifyJsonWithinByteLimit(result ?? null, MAX_GIT_RESPONSE_STREAM_BYTES) + serialized = bounded.serialized + payloadBytes = bounded.byteLength + } catch (error) { + if (!(error instanceof JsonStringifyByteLimitError)) { + throw error + } + const oversized = new Error( + `Git response is too large to transfer safely (more than ${MAX_GIT_RESPONSE_STREAM_BYTES} bytes)` + ) as Error & { code: number } + oversized.code = RelayErrorCode.StreamProtocolError + throw oversized + } + if (payloadBytes <= GIT_RESPONSE_STREAM_THRESHOLD) { return result } - return this.responseStreams.startStream(payload, this.dispatcher, context) + return this.responseStreams.startStream(serialized, this.dispatcher, context) } private clearGitMutationReadCaches(): void { @@ -711,7 +715,7 @@ export class GitHandler { worktreePath ) // Why: a selected tracked directory can make `ls-files -z` return enough descendants for push(...split) to exceed the argument limit. - for (const trackedPathSpec of stdout.split('\0')) { + for (const trackedPathSpec of iterateNulDelimitedFields(stdout)) { if (trackedPathSpec) { trackedPathSpecs.push(trackedPathSpec) } @@ -949,94 +953,38 @@ export class GitHandler { } } - // Why: the durable review-head ref embeds the remote's identity, and a - // missing remote must fail with an actionable message, not a raw fetch error. - private async reviewHeadRemoteComponent(worktreePath: string, remote: string): Promise { - let remoteUrl: string - try { - const { stdout } = await this.git(['remote', 'get-url', remote], worktreePath) - remoteUrl = stdout.trim() - } catch { - remoteUrl = '' - } - if (!remoteUrl) { - throw new Error(`Remote "${remote}" is not configured.`) - } - return reviewHeadRemoteRefComponent(remote, remoteUrl) - } - private async fetchGitLabMergeRequestHead(params: Record) { this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const remote = params.remote const mrIid = params.mrIid try { - if (typeof remote !== 'string' || !isValidReviewHeadNumber(mrIid)) { + if (typeof remote !== 'string') { + throw new Error('Invalid GitLab merge request fetch request.') + } + if (typeof mrIid !== 'number' || !Number.isSafeInteger(mrIid) || mrIid <= 0) { throw new Error('Invalid GitLab merge request fetch request.') } const mergeRequestIid = mrIid - if (!isSafeReviewHeadFetchRemote(remote)) { + if (remote.startsWith('-')) { throw new Error('GitLab merge request fetch remote must not start with "-".') } try { - const remoteComponent = await this.reviewHeadRemoteComponent(worktreePath, remote) - // Why: GitLab fork heads need a dedicated write RPC and ref outside refs/heads/*. - // Return the exact written path so the client does not re-hash a second get-url. - const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mergeRequestIid) - await this.git( - [ - 'fetch', - '--no-tags', - remote, - `+refs/merge-requests/${mergeRequestIid}/head:${localRef}` - ], - worktreePath, - { timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } - ) - return { localRef } - } catch (error) { - // Why: a timeout kill has no git stderr; name it so the client can classify it as transient. - if (isExecKilledError(error)) { - throw new Error( - `Fetching refs/merge-requests/${mergeRequestIid}/head from "${remote}" timed out.` - ) + const { stdout } = await this.git(['remote'], worktreePath) + const remotes = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + if (!remotes.includes(remote)) { + throw new Error(`Remote "${remote}" is not configured.`) } - throw new Error(normalizeGitErrorMessage(error, 'fetch')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - private async fetchGitHubPullRequestHead(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const remote = params.remote - const prNumber = params.prNumber - try { - if (typeof remote !== 'string' || !isValidReviewHeadNumber(prNumber)) { - throw new Error('Invalid GitHub pull request fetch request.') - } - if (!isSafeReviewHeadFetchRemote(remote)) { - throw new Error('GitHub pull request fetch remote must not start with "-".') - } - - try { - const remoteComponent = await this.reviewHeadRemoteComponent(worktreePath, remote) - // Why: return the written path so resolve can rev-parse the same ref the host wrote. - const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber) + // Why: GitLab MR heads aren't refs/heads/*, so the remote-tracking fetch RPC can't represent fork MRs; keep this write path MR-only. await this.git( - ['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`], - worktreePath, - { timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } + ['fetch', '--no-tags', remote, `refs/merge-requests/${mergeRequestIid}/head`], + worktreePath ) - return { localRef } } catch (error) { - // Why: a timeout kill has no git stderr; name it so the client can classify it as transient. - if (isExecKilledError(error)) { - throw new Error(`Fetching refs/pull/${prNumber}/head from "${remote}" timed out.`) - } throw new Error(normalizeGitErrorMessage(error, 'fetch')) } } finally { diff --git a/src/relay/git-response-stream-ownership.test.ts b/src/relay/git-response-stream-ownership.test.ts index c6340265238..758a898d8ee 100644 --- a/src/relay/git-response-stream-ownership.test.ts +++ b/src/relay/git-response-stream-ownership.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RelayDispatcher, RequestContext } from './dispatcher' -import { GitResponseStreamRegistry } from './git-response-stream' +import { + GitResponseStreamRegistry, + MAX_CONCURRENT_GIT_RESPONSE_STREAMS +} from './git-response-stream' import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from './protocol' async function flushPump(): Promise { @@ -37,10 +40,20 @@ describe('GitResponseStreamRegistry client ownership', () => { await flushPump() expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) + registry.recordAck(streamId, 10_000, ownerClientId) + await flushPump() + expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) + registry.recordAck(streamId, 10_000, ownerClientId + 1) await flushPump() expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) + for (const invalidSeq of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { + registry.recordAck(streamId, invalidSeq, ownerClientId) + } + await flushPump() + expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) + registry.abort(streamId, ownerClientId + 1) registry.recordAck(streamId, STREAM_ACK_WINDOW_CHUNKS - 1, ownerClientId) await flushPump() @@ -65,4 +78,115 @@ describe('GitResponseStreamRegistry client ownership', () => { expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') expect(notifyBulk.mock.calls[1]?.[0]).toBe('git.responseError') }) + + it('caps parked streams and defers base64 expansion until the pump runs', () => { + const dispatcher = { + notifyBulk: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } as unknown as RelayDispatcher + const registry = new GitResponseStreamRegistry() + registries.push(registry) + const payload = Buffer.from('payload') + const toStringSpy = vi.spyOn(Buffer.prototype, 'toString') + + try { + for (let index = 0; index < MAX_CONCURRENT_GIT_RESPONSE_STREAMS; index += 1) { + registry.startStream(payload, dispatcher, { + clientId: 7, + isStale: () => false + }) + } + + expect(toStringSpy.mock.calls.some(([encoding]) => encoding === 'base64')).toBe(false) + expect(() => + registry.startStream(payload, dispatcher, { + clientId: 7, + isStale: () => false + }) + ).toThrow(`Too many concurrent git response streams`) + } finally { + toStringSpy.mockRestore() + } + }) + + it('caps aggregate bytes retained by concurrent parked responses', () => { + const dispatcher = { + notifyBulk: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } as unknown as RelayDispatcher + const registry = new GitResponseStreamRegistry(10) + registries.push(registry) + const context = { clientId: 7, isStale: () => false } + + registry.startStream(Buffer.alloc(6), dispatcher, context) + registry.startStream(Buffer.alloc(4), dispatcher, context) + + expect(() => registry.startStream(Buffer.alloc(1), dispatcher, context)).toThrow( + 'Concurrent git responses exceed retained-byte limit (10 bytes)' + ) + }) + + it('rejects a serialized string before allocating any encoded chunk', () => { + const dispatcher = { + notifyBulk: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } as unknown as RelayDispatcher + const registry = new GitResponseStreamRegistry(0) + registries.push(registry) + const fromSpy = vi.spyOn(Buffer, 'from') + + try { + expect(() => + registry.startStream('serialized response', dispatcher, { + clientId: 7, + isStale: () => false + }) + ).toThrow('Concurrent git responses exceed retained-byte limit (0 bytes)') + expect(fromSpy).not.toHaveBeenCalled() + } finally { + fromSpy.mockRestore() + } + }) + + it('streams a serialized string without changing its UTF-8 bytes', async () => { + const chunks: Buffer[] = [] + const notifyBulk = vi.fn().mockImplementation((method, params) => { + if (method === 'git.responseChunk') { + chunks.push(Buffer.from(params.data, 'base64')) + } + return Promise.resolve() + }) + const dispatcher = { notifyBulk, notify: vi.fn() } as unknown as RelayDispatcher + const registry = new GitResponseStreamRegistry() + registries.push(registry) + const serialized = JSON.stringify({ text: `boundary-${'🐋'.repeat(40_000)}` }) + + const marker = registry.startStream(serialized, dispatcher, { + clientId: 7, + isStale: () => false + }) + await flushPump() + await flushPump() + + expect(Buffer.concat(chunks)).toEqual(Buffer.from(serialized, 'utf8')) + expect(chunks).toHaveLength(marker.__orcaGitResponseStream.chunkCount) + expect(marker.__orcaGitResponseStream.totalBytes).toBe(Buffer.byteLength(serialized, 'utf8')) + }) + + it('releases aggregate bytes after completion and disposal', async () => { + const dispatcher = { + notifyBulk: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } as unknown as RelayDispatcher + const registry = new GitResponseStreamRegistry(10) + registries.push(registry) + const context = { clientId: 7, isStale: () => false } + + registry.startStream(Buffer.alloc(10), dispatcher, context) + await flushPump() + expect(() => registry.startStream(Buffer.alloc(10), dispatcher, context)).not.toThrow() + + registry.disposeAll() + expect(() => registry.startStream(Buffer.alloc(10), dispatcher, context)).not.toThrow() + }) }) diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts index bced3b47e46..2cdb6a73944 100644 --- a/src/relay/git-response-stream.ts +++ b/src/relay/git-response-stream.ts @@ -6,42 +6,95 @@ import type { RelayDispatcher, RequestContext } from './dispatcher' import { GIT_RESPONSE_CHUNK_SIZE, + MAX_GIT_RESPONSE_STREAM_BYTES, + MAX_GIT_RESPONSE_STREAM_CHUNKS, + MAX_CONCURRENT_STREAMS, + RelayErrorCode, STREAM_ACK_WINDOW_CHUNKS, STREAM_ACK_STALL_RECHECK_MS, type GitResponseStreamMarker } from './protocol' +import { + encodeUtf8StringChunk, + planUtf8StringChunks, + type Utf8StringChunk +} from './git-response-utf8-chunks' type GitResponseStreamEntry = { ownerClientId: number + retainedBytes: number aborted: boolean + /** Highest chunk seq admitted to the outbound bulk lane. */ + sentThroughSeq: number /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */ ackedThroughSeq: number ackWaiters: Set<() => void> } -/** Serialized git responses are chunked as base64 so multi-byte UTF-8 - * sequences never split across a chunk boundary (the client concatenates the - * decoded bytes and parses once). */ -function encodeChunks(payload: Buffer): string[] { - const chunks: string[] = [] - for (let offset = 0; offset < payload.length; offset += GIT_RESPONSE_CHUNK_SIZE) { - chunks.push(payload.subarray(offset, offset + GIT_RESPONSE_CHUNK_SIZE).toString('base64')) - } - return chunks -} +type PreparedGitResponse = + | { kind: 'buffer'; value: Buffer; byteLength: number; chunkCount: number } + | { + kind: 'string' + value: string + byteLength: number + chunks: Utf8StringChunk[] + chunkCount: number + } + +export const MAX_CONCURRENT_GIT_RESPONSE_STREAMS = MAX_CONCURRENT_STREAMS +export const MAX_ACTIVE_GIT_RESPONSE_STREAM_BYTES = 128 * 1024 * 1024 export class GitResponseStreamRegistry { private streams = new Map() private nextId = 1 + private retainedBytes = 0 - private register(ownerClientId: number): number { + constructor(private readonly maxRetainedBytes: number = MAX_ACTIVE_GIT_RESPONSE_STREAM_BYTES) {} + + private preparePayload(payload: Buffer | string): PreparedGitResponse { + if (Buffer.isBuffer(payload)) { + return { + kind: 'buffer', + value: payload, + byteLength: payload.length, + chunkCount: Math.ceil(payload.length / GIT_RESPONSE_CHUNK_SIZE) + } + } + const chunks = planUtf8StringChunks(payload, GIT_RESPONSE_CHUNK_SIZE) + return { + kind: 'string', + value: payload, + byteLength: Buffer.byteLength(payload, 'utf8'), + chunks, + chunkCount: chunks.length + } + } + + private register(ownerClientId: number, retainedBytes: number): number { + if (this.streams.size >= MAX_CONCURRENT_GIT_RESPONSE_STREAMS) { + const error = new Error( + `Too many concurrent git response streams (max ${MAX_CONCURRENT_GIT_RESPONSE_STREAMS})` + ) as Error & { code: number } + error.code = RelayErrorCode.TooManyStreams + throw error + } + if (retainedBytes > this.maxRetainedBytes - this.retainedBytes) { + const error = new Error( + `Concurrent git responses exceed retained-byte limit (${this.maxRetainedBytes} bytes)` + ) as Error & { code: number } + error.code = RelayErrorCode.TooManyStreams + throw error + } const streamId = this.nextId++ this.streams.set(streamId, { ownerClientId, + retainedBytes, aborted: false, + sentThroughSeq: -1, ackedThroughSeq: -1, ackWaiters: new Set() }) + this.retainedBytes += retainedBytes return streamId } @@ -50,8 +103,9 @@ export class GitResponseStreamRegistry { if ( !entry || entry.ownerClientId !== clientId || - typeof seq !== 'number' || - !Number.isFinite(seq) + !Number.isSafeInteger(seq) || + seq < 0 || + seq > entry.sentThroughSeq ) { return } @@ -111,25 +165,39 @@ export class GitResponseStreamRegistry { * sentinel marker to send as the RPC result. */ startStream( - payload: Buffer, + payload: Buffer | string, dispatcher: RelayDispatcher, context: RequestContext ): GitResponseStreamMarker { - const streamId = this.register(context.clientId) - const chunks = encodeChunks(payload) + const prepared = this.preparePayload(payload) + if ( + prepared.byteLength > MAX_GIT_RESPONSE_STREAM_BYTES || + prepared.chunkCount > MAX_GIT_RESPONSE_STREAM_CHUNKS + ) { + const error = new Error( + `Git response exceeds stream limit (${prepared.byteLength} bytes, ${prepared.chunkCount} chunks)` + ) as Error & { code: number } + error.code = RelayErrorCode.StreamProtocolError + throw error + } + const streamId = this.register(context.clientId, prepared.byteLength) // Why: kick the pump off the response task so the client sees the sentinel // (and can subscribe/reassemble) before the first chunk frame arrives. setImmediate(() => { - void this.pump(streamId, chunks, dispatcher, context) + void this.pump(streamId, prepared, dispatcher, context) }) return { - __orcaGitResponseStream: { streamId, totalBytes: payload.length, chunkCount: chunks.length } + __orcaGitResponseStream: { + streamId, + totalBytes: prepared.byteLength, + chunkCount: prepared.chunkCount + } } } private async pump( streamId: number, - chunks: string[], + payload: PreparedGitResponse, dispatcher: RelayDispatcher, context: RequestContext ): Promise { @@ -141,7 +209,7 @@ export class GitResponseStreamRegistry { let seq = 0 let endReason: 'end' | 'aborted' | 'stale' = 'end' try { - for (seq = 0; seq < chunks.length; seq += 1) { + for (seq = 0; seq < payload.chunkCount; seq += 1) { if (context.isStale()) { endReason = 'stale' break @@ -167,11 +235,20 @@ export class GitResponseStreamRegistry { endReason = 'aborted' break } - // Why: notifyBulk waits out sink saturation so chunk frames never pile - // up in the outbound pipe ahead of interactive pty.data frames. + const chunk = + payload.kind === 'buffer' + ? payload.value.subarray( + seq * GIT_RESPONSE_CHUNK_SIZE, + (seq + 1) * GIT_RESPONSE_CHUNK_SIZE + ) + : encodeUtf8StringChunk(payload.value, payload.chunks[seq]) + // Why: encode only the current chunk; eager base64 retained a second, + // 4/3-expanded copy of every parked response until its final ACK. + const data = chunk.toString('base64') + entry.sentThroughSeq = seq await dispatcher.notifyBulk( 'git.responseChunk', - { streamId, seq, data: chunks[seq] }, + { streamId, seq, data }, { clientId } @@ -197,15 +274,25 @@ export class GitResponseStreamRegistry { } } } finally { - this.streams.delete(streamId) + this.deleteStream(streamId) } } + private deleteStream(streamId: number): void { + const entry = this.streams.get(streamId) + if (!entry) { + return + } + this.streams.delete(streamId) + this.retainedBytes -= entry.retainedBytes + } + disposeAll(): void { for (const entry of this.streams.values()) { entry.aborted = true this.wake(entry) } this.streams.clear() + this.retainedBytes = 0 } } diff --git a/src/relay/git-response-utf8-chunks.test.ts b/src/relay/git-response-utf8-chunks.test.ts new file mode 100644 index 00000000000..1b2239dcb90 --- /dev/null +++ b/src/relay/git-response-utf8-chunks.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { encodeUtf8StringChunk, planUtf8StringChunks } from './git-response-utf8-chunks' + +describe('Git response UTF-8 chunk planning', () => { + it('round-trips mixed-width text without splitting code points', () => { + const value = 'ascii-é-€-🐋-end'.repeat(20) + const plan = planUtf8StringChunks(value, 17) + const encoded = plan.map((chunk) => encodeUtf8StringChunk(value, chunk)) + + expect(Buffer.concat(encoded)).toEqual(Buffer.from(value, 'utf8')) + expect(plan.every((chunk) => chunk.byteLength <= 17)).toBe(true) + expect(plan.reduce((total, chunk) => total + chunk.byteLength, 0)).toBe( + Buffer.byteLength(value, 'utf8') + ) + }) + + it('matches Node replacement encoding for unmatched surrogate code units', () => { + const value = 'before-\ud800-after-\udc00' + const plan = planUtf8StringChunks(value, 8) + + expect(Buffer.concat(plan.map((chunk) => encodeUtf8StringChunk(value, chunk)))).toEqual( + Buffer.from(value, 'utf8') + ) + }) + + it('returns no chunks for empty text and rejects unsafe chunk sizes', () => { + expect(planUtf8StringChunks('', 4)).toEqual([]) + expect(() => planUtf8StringChunks('value', 3)).toThrow(RangeError) + }) +}) diff --git a/src/relay/git-response-utf8-chunks.ts b/src/relay/git-response-utf8-chunks.ts new file mode 100644 index 00000000000..1c55dbeed5b --- /dev/null +++ b/src/relay/git-response-utf8-chunks.ts @@ -0,0 +1,55 @@ +export type Utf8StringChunk = { + start: number + end: number + byteLength: number +} + +function encodedCodePointSize(value: string, index: number): { bytes: number; codeUnits: number } { + const code = value.charCodeAt(index) + if (code <= 0x7f) { + return { bytes: 1, codeUnits: 1 } + } + if (code <= 0x7ff) { + return { bytes: 2, codeUnits: 1 } + } + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + return { bytes: 4, codeUnits: 2 } + } + } + return { bytes: 3, codeUnits: 1 } +} + +export function planUtf8StringChunks(value: string, maxChunkBytes: number): Utf8StringChunk[] { + if (!Number.isSafeInteger(maxChunkBytes) || maxChunkBytes < 4) { + throw new RangeError('UTF-8 chunk limit must be a safe integer of at least 4 bytes') + } + + const chunks: Utf8StringChunk[] = [] + let chunkStart = 0 + let chunkBytes = 0 + let index = 0 + while (index < value.length) { + const encoded = encodedCodePointSize(value, index) + if (chunkBytes > 0 && chunkBytes + encoded.bytes > maxChunkBytes) { + chunks.push({ start: chunkStart, end: index, byteLength: chunkBytes }) + chunkStart = index + chunkBytes = 0 + } + chunkBytes += encoded.bytes + index += encoded.codeUnits + } + if (chunkBytes > 0) { + chunks.push({ start: chunkStart, end: value.length, byteLength: chunkBytes }) + } + return chunks +} + +export function encodeUtf8StringChunk(value: string, chunk: Utf8StringChunk): Buffer { + const encoded = Buffer.from(value.slice(chunk.start, chunk.end), 'utf8') + if (encoded.length !== chunk.byteLength) { + throw new Error('UTF-8 chunk plan did not match encoded byte length') + } + return encoded +} diff --git a/src/relay/git-status-upstream-negative-cache.test.ts b/src/relay/git-status-upstream-negative-cache.test.ts index c69fa11e4e8..e6085e3b2ce 100644 --- a/src/relay/git-status-upstream-negative-cache.test.ts +++ b/src/relay/git-status-upstream-negative-cache.test.ts @@ -4,6 +4,7 @@ import { clearNoEffectiveUpstreamStatusCacheEntry, getNoEffectiveUpstreamStatusCacheCountForTests, getNoEffectiveUpstreamStatusGenerationCountForTests, + MAX_NO_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES, readOrProbeNoEffectiveUpstreamStatus } from './git-status-upstream-negative-cache' @@ -327,6 +328,59 @@ describe('relay upstream negative cache', () => { expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) }) + it('retains the exact key-byte boundary but not oversized identities', async () => { + const worktreePath = '/r' + const keyOverheadBytes = Buffer.byteLength(`${worktreePath}\0\0`, 'utf8') + const exactBranch = 'x'.repeat(MAX_NO_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES - keyOverheadBytes) + const oversizedBranch = `${exactBranch}x` + const createRunner = (branchName: string) => + vi.fn(async (args: string[]): Promise<{ stdout: string }> => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (isConfigListSnapshotCommand(args)) { + return emptyGitConfigSnapshot() + } + if (args[0] === 'rev-parse') { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const exactRunner = createRunner(exactBranch) + const oversizedRunner = createRunner(oversizedBranch) + + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath, branchName: exactBranch }, + exactRunner + ) + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath, branchName: exactBranch }, + exactRunner + ) + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath, branchName: oversizedBranch }, + oversizedRunner + ) + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath, branchName: oversizedBranch }, + oversizedRunner + ) + clearNoEffectiveUpstreamStatusCacheEntry({ + worktreePath, + branchName: oversizedBranch + }) + + expect(exactRunner.mock.calls.filter(([args]) => args[0] === 'symbolic-ref')).toHaveLength(1) + expect(oversizedRunner.mock.calls.filter(([args]) => args[0] === 'symbolic-ref')).toHaveLength( + 2 + ) + expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(1) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBe(0) + }) + it('coalesces no-upstream config reads into one snapshot subprocess', async () => { const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { if (args[0] === 'symbolic-ref') { diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts index e3ad9239cc9..387c2a8b060 100644 --- a/src/relay/git-status-upstream-negative-cache.ts +++ b/src/relay/git-status-upstream-negative-cache.ts @@ -5,6 +5,7 @@ import type { GitUpstreamStatus } from '../shared/types' const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 5 * 60_000 const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES = 512 +export const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES = 64 * 1024 type NoEffectiveUpstreamCacheIdentity = { worktreePath: string @@ -26,6 +27,10 @@ function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity) return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0') } +function canRetainNoEffectiveUpstreamCacheKey(cacheKey: string): boolean { + return Buffer.byteLength(cacheKey, 'utf8') <= MAX_NO_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES +} + function readCachedNoEffectiveUpstreamStatus( cacheKey: string, nowMs = Date.now() @@ -66,6 +71,11 @@ function cacheNoEffectiveUpstreamStatus( writeGeneration: number, nowMs = Date.now() ): void { + if (!canRetainNoEffectiveUpstreamCacheKey(cacheKey)) { + noEffectiveUpstreamByIdentity.delete(cacheKey) + noEffectiveUpstreamWriteGeneration.delete(cacheKey) + return + } // Why: hasConfiguredPushTarget controls publish behavior; keep that signal // fresh rather than serving a stale positive from status polling. if (status.hasUpstream || status.hasConfiguredPushTarget) { @@ -154,6 +164,10 @@ export function clearNoEffectiveUpstreamStatusCacheEntry( retireNoEffectiveUpstreamProbe(cacheKey) noEffectiveUpstreamByIdentity.delete(cacheKey) noEffectiveUpstreamInFlight.delete(cacheKey) + if (!canRetainNoEffectiveUpstreamCacheKey(cacheKey)) { + noEffectiveUpstreamWriteGeneration.delete(cacheKey) + return + } noEffectiveUpstreamWriteGeneration.set( cacheKey, (noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) + 1 diff --git a/src/relay/git-working-file-read.test.ts b/src/relay/git-working-file-read.test.ts index 7f9824ead94..4f52b30196c 100644 --- a/src/relay/git-working-file-read.test.ts +++ b/src/relay/git-working-file-read.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -29,7 +29,8 @@ describe('readWorkingDiffFile', () => { it('marks oversized working-tree files as binary before diffing', async () => { tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) const filePath = path.join(tmpDir, 'large.log') - await writeFile(filePath, Buffer.alloc(10 * 1024 * 1024 + 1, 'a')) + await writeFile(filePath, 'a') + await truncate(filePath, 10 * 1024 * 1024 + 1) await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ content: '', diff --git a/src/relay/git-working-file-read.ts b/src/relay/git-working-file-read.ts index 05cf1ebcdce..a8beb5446e4 100644 --- a/src/relay/git-working-file-read.ts +++ b/src/relay/git-working-file-read.ts @@ -1,5 +1,9 @@ -import { readFile, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' import { bufferToBlob } from './git-handler-utils' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../shared/memory-safety/node-bounded-file-reader' const MAX_RELAY_DIFF_WORKING_FILE_BYTES = 10 * 1024 * 1024 @@ -19,16 +23,16 @@ export async function readWorkingDiffFile( if (!fileStat.isFile()) { return { content: '', isBinary: false, missing: true } } - if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) { - // Why: mirror local git diff reads, which cap blob transfer at 10MB. - return { content: '', isBinary: true, missing: false } - } try { - const buffer = await readFile(absPath) + const { buffer } = await readNodeFileWithinLimit(absPath, MAX_RELAY_DIFF_WORKING_FILE_BYTES) // Why: bufferToBlob needs the path's extension to know an image is // previewable; omitting it made every relay-side binary diff empty. return { ...bufferToBlob(buffer, absPath), missing: false } - } catch { + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + // Why: mirror local git diff reads, which classify blobs over 10MB as binary. + return { content: '', isBinary: true, missing: false } + } // Why: the file exists but could not be read — a read failure, not a deletion. return { content: '', isBinary: false, missing: false } } diff --git a/src/relay/integration.test.ts b/src/relay/integration.test.ts index 71d09c78de6..a3fcfd75adb 100644 --- a/src/relay/integration.test.ts +++ b/src/relay/integration.test.ts @@ -23,6 +23,7 @@ import { RelayDispatcher } from './dispatcher' import { RelayContext } from './context' import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' +import { SshFilesystemProvider } from '../main/providers/ssh-filesystem-provider' function gitInit(dir: string): void { execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) @@ -108,6 +109,17 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { expect(names).toEqual(['hello.txt', 'readme.md']) }) + it('routes SSH provider directory reads through the bounded relay handler', async () => { + writeFileSync(path.join(tmpDir, 'hello.txt'), 'world') + const provider = new SshFilesystemProvider('test-connection', mux) + + await expect(provider.readDir(tmpDir)).resolves.toEqual([ + { name: 'hello.txt', isDirectory: false, isSymlink: false } + ]) + + provider.dispose() + }) + it('readFile returns text content', async () => { writeFileSync(path.join(tmpDir, 'data.txt'), 'some content') @@ -122,6 +134,11 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { it('readFileStream round-trip preserves a 12 MB binary file', async () => { const filePath = path.join(tmpDir, 'big.png') const original = randomBytes(12 * 1024 * 1024) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(original) + original.writeUInt32BE(13, 8) + original.write('IHDR', 12, 'ascii') + original.writeUInt32BE(1, 16) + original.writeUInt32BE(1, 20) writeFileSync(filePath, original) const { readFileViaStream } = await import('../main/ssh/ssh-filesystem-stream-reader') const { content } = await readFileViaStream(mux, filePath) diff --git a/src/relay/markdown-document-listing.test.ts b/src/relay/markdown-document-listing.test.ts new file mode 100644 index 00000000000..38f93db2ee6 --- /dev/null +++ b/src/relay/markdown-document-listing.test.ts @@ -0,0 +1,93 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import { + MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS, + MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES, + MarkdownDocumentListingCapacityError +} from '../shared/markdown-document-listing-limits' +import { + listMarkdownPathsWithRg, + listRelayMarkdownDocumentPaths +} from './markdown-document-listing' + +function successfulChild(output: Buffer): ChildProcess { + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) as unknown as ChildProcess + queueMicrotask(() => { + child.stdout!.emit('data', output) + child.emit('close', 0, null) + }) + return child +} + +describe('relay Markdown document producer', () => { + it('returns only Markdown paths from a real under-limit workspace', async () => { + const rootPath = await mkdtemp(join(tmpdir(), 'orca-markdown-listing-')) + try { + await mkdir(join(rootPath, 'docs')) + await writeFile(join(rootPath, 'README.md'), 'readme') + await writeFile(join(rootPath, 'docs', 'Guide.MDX'), 'guide') + await writeFile(join(rootPath, 'docs', 'app.ts'), 'code') + + const result = await listRelayMarkdownDocumentPaths(rootPath) + expect(result.sort()).toEqual(['README.md', 'docs/Guide.MDX']) + } finally { + await rm(rootPath, { recursive: true, force: true }) + } + }) + + it('filters a large non-Markdown repository before applying document retention limits', async () => { + const nonMarkdown = Array.from({ length: 100_001 }, (_value, index) => `src/${index}.ts\0`) + const output = Buffer.from(`${nonMarkdown.join('')}README.md\0docs/GUIDE.MDX\0`) + const spawnProcess = vi.fn((_args: string[]) => successfulChild(output)) + + await expect(listMarkdownPathsWithRg('/repo', undefined, spawnProcess)).resolves.toEqual([ + 'README.md', + 'docs/GUIDE.MDX' + ]) + expect(spawnProcess).toHaveBeenCalledTimes(2) + expect(spawnProcess.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining(['--iglob', '*.md', '--null']) + ) + }) + + it('rejects rather than truncating the first document beyond the count limit', async () => { + const output = Buffer.from( + Array.from( + { length: MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS + 1 }, + (_value, index) => `${index}.md\0` + ).join('') + ) + const children: ChildProcess[] = [] + const spawnProcess = vi.fn(() => { + const child = successfulChild(output) + children.push(child) + return child + }) + + await expect(listMarkdownPathsWithRg('/repo', undefined, spawnProcess)).rejects.toBeInstanceOf( + MarkdownDocumentListingCapacityError + ) + expect(children[0]?.kill).toHaveBeenCalled() + expect(spawnProcess).toHaveBeenCalledTimes(1) + }) + + it('bounds an unterminated subprocess path before decoding or retaining it', async () => { + const child = successfulChild(Buffer.alloc(MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES + 1, 0x61)) + + await expect(listMarkdownPathsWithRg('/repo', undefined, () => child)).rejects.toBeInstanceOf( + MarkdownDocumentListingCapacityError + ) + expect(child.kill).toHaveBeenCalled() + }) +}) diff --git a/src/relay/markdown-document-listing.ts b/src/relay/markdown-document-listing.ts new file mode 100644 index 00000000000..a4330f3442b --- /dev/null +++ b/src/relay/markdown-document-listing.ts @@ -0,0 +1,238 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { checkRgAvailable } from './fs-handler-utils' +import { + buildRgArgsForQuickOpen, + normalizeQuickOpenRgLine, + shouldIncludeQuickOpenPath +} from '../shared/quick-open-filter' +import { + assertMarkdownDocumentPathWithinLimit, + createMarkdownDocumentListingBudget, + isMarkdownDocumentListingCapacityError, + MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE, + MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES, + MarkdownDocumentListingCapacityError, + retainMarkdownRelativePath, + visitMarkdownDocumentListingEntry +} from '../shared/markdown-document-listing-limits' +import { + discoverMarkdownRelativePaths, + isMarkdownDocumentPath +} from '../shared/node-markdown-document-discovery' +import { fileListingCancellationError } from '../shared/file-listing-cancellation' +import { RelayErrorCode } from './protocol' +import { GrowingByteBuffer } from '../shared/growing-byte-buffer' + +const MARKDOWN_LISTING_TIMEOUT_MS = 25_000 +const MARKDOWN_GLOBS = ['*.md', '*.mdx', '*.markdown'] + +class MarkdownPathFieldAccumulator { + private readonly field = new GrowingByteBuffer() + + push(chunk: Buffer, onPath: (path: string) => void): boolean { + let cursor = 0 + while (cursor < chunk.length) { + const delimiter = chunk.indexOf(0, cursor) + const end = delimiter === -1 ? chunk.length : delimiter + const segmentBytes = end - cursor + if (this.field.byteLength + segmentBytes > MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES) { + this.clear() + return false + } + if (delimiter !== -1 && this.field.byteLength === 0) { + onPath(chunk.toString('utf8', cursor, end)) + } else if (segmentBytes > 0) { + this.field.append(chunk.subarray(cursor, end)) + if (delimiter !== -1) { + onPath(this.take()) + } + } else if (delimiter !== -1) { + onPath(this.take()) + } + if (delimiter === -1) { + return true + } + cursor = delimiter + 1 + } + return true + } + + finish(): string | null { + return this.field.byteLength > 0 ? this.take() : null + } + + private take(): string { + return this.field.takeString() + } + + private clear(): void { + this.field.clear() + } +} + +function markdownRgArgs(args: string[]): string[] { + const target = args.at(-1) + if (!target) { + throw new Error('Markdown rg scan is missing its search root') + } + const result = [...args.slice(0, -1), '--null'] + for (const glob of MARKDOWN_GLOBS) { + result.push('--iglob', glob) + } + result.push(target) + return result +} + +function relativePathDepth(path: string): number { + let depth = 0 + for (let index = 0; index < path.length; index += 1) { + if (path[index] === '/') { + depth += 1 + } + } + return depth +} + +export async function listMarkdownPathsWithRg( + rootPath: string, + signal?: AbortSignal, + spawnProcess: (args: string[]) => ChildProcess = (args) => + spawn('rg', args, { cwd: rootPath, stdio: ['ignore', 'pipe', 'pipe'] }) +): Promise { + const paths = new Set() + const budget = createMarkdownDocumentListingBudget() + assertMarkdownDocumentPathWithinLimit(rootPath, budget.limits.maxPathBytes) + const passes = buildRgArgsForQuickOpen({ + searchRoot: '.', + excludePathPrefixes: [], + forceSlashSeparator: true + }) + + const runPass = (args: string[]): Promise => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(fileListingCancellationError(signal)) + return + } + const child = spawnProcess(markdownRgArgs(args)) + const fields = new MarkdownPathFieldAccumulator() + let done = false + let parseablePathCount = 0 + let timer: ReturnType | null = null + + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + child.stdout?.off('data', onStdout) + child.stderr?.off('data', onStderr) + child.off('error', onError) + child.off('close', onClose) + signal?.removeEventListener('abort', onAbort) + } + const finish = (error?: Error, kill = false): void => { + if (done) { + return + } + done = true + cleanup() + if (kill && child.exitCode === null && child.signalCode === null) { + child.kill() + } + if (error) { + reject(error) + } else { + resolve() + } + } + const processPath = (rawPath: string): void => { + const relativePath = normalizeQuickOpenRgLine(rawPath, { kind: 'cwd-relative' }) + if (relativePath === null) { + return + } + parseablePathCount += 1 + if ( + paths.has(relativePath) || + !shouldIncludeQuickOpenPath(relativePath) || + !isMarkdownDocumentPath(relativePath) + ) { + return + } + visitMarkdownDocumentListingEntry(budget, relativePath, relativePathDepth(relativePath)) + retainMarkdownRelativePath(budget, rootPath, relativePath) + paths.add(relativePath) + } + const onStdout = (chunk: Buffer): void => { + try { + if (!fields.push(chunk, processPath)) { + finish(new MarkdownDocumentListingCapacityError(), true) + } + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error)), true) + } + } + const onStderr = (): void => {} + const onError = (error: Error): void => finish(error) + const onClose = (code: number | null, exitSignal: NodeJS.Signals | null): void => { + if (exitSignal) { + finish(new Error(`rg killed by ${exitSignal}`)) + return + } + try { + const trailingPath = fields.finish() + if (trailingPath !== null) { + processPath(trailingPath) + } + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + return + } + if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) { + finish() + } else { + finish(new Error(`rg exited with code ${code}`)) + } + } + const onAbort = (): void => finish(fileListingCancellationError(signal), true) + + child.stdout?.on('data', onStdout) + child.stderr?.on('data', onStderr) + child.once('error', onError) + child.once('close', onClose) + signal?.addEventListener('abort', onAbort, { once: true }) + timer = setTimeout( + () => finish(new Error('Markdown document listing timed out'), true), + MARKDOWN_LISTING_TIMEOUT_MS + ) + }) + + await runPass(passes.primary) + await runPass(passes.ignoredPass) + return Array.from(paths) +} + +export async function listRelayMarkdownDocumentPaths( + rootPath: string, + signal?: AbortSignal +): Promise { + try { + if (await checkRgAvailable()) { + return await listMarkdownPathsWithRg(rootPath, signal) + } + return await discoverMarkdownRelativePaths(rootPath, { + ignoreNestedDirectoryErrors: true, + shouldDescend: (relativePath) => shouldIncludeQuickOpenPath(relativePath), + signal + }) + } catch (error) { + if (!isMarkdownDocumentListingCapacityError(error)) { + throw error + } + const relayError = new Error(MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE) as Error & { + code: number + } + relayError.code = RelayErrorCode.MarkdownDocumentListingCapacity + throw relayError + } +} diff --git a/src/relay/mobile-file-directory-reader.test.ts b/src/relay/mobile-file-directory-reader.test.ts new file mode 100644 index 00000000000..8000dad5c00 --- /dev/null +++ b/src/relay/mobile-file-directory-reader.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' + +const { statMock } = vi.hoisted(() => ({ statMock: vi.fn() })) + +vi.mock('node:fs/promises', () => ({ opendir: vi.fn(), stat: statMock })) + +import { collectMobileRelayDirectoryEntries } from './mobile-file-directory-reader' + +describe('mobile relay directory reader', () => { + it('stops enumeration at the mobile entry limit', async () => { + let enumerated = 0 + const directory = { + async *[Symbol.asyncIterator]() { + while (enumerated < 20_000) { + enumerated += 1 + yield { + name: 'entry', + isDirectory: () => false, + isSymbolicLink: () => false + } + } + } + } + + await expect(collectMobileRelayDirectoryEntries('/repo', directory)).rejects.toThrow( + 'This folder is too large to show safely on mobile' + ) + expect(enumerated).toBe(10_001) + }) + + it('bounds concurrent symlink stat work while preserving entry order', async () => { + let active = 0 + let maxActive = 0 + statMock.mockImplementation(async () => { + active += 1 + maxActive = Math.max(maxActive, active) + await new Promise((resolve) => setImmediate(resolve)) + active -= 1 + return { isDirectory: () => true } + }) + const directory = { + async *[Symbol.asyncIterator]() { + for (let index = 0; index < 100; index += 1) { + yield { + name: `link-${index}`, + isDirectory: () => false, + isSymbolicLink: () => true + } + } + } + } + + const entries = await collectMobileRelayDirectoryEntries('/repo', directory) + + expect(maxActive).toBe(32) + expect(entries.map((entry) => entry.name)).toEqual( + Array.from({ length: 100 }, (_, index) => `link-${index}`) + ) + expect(entries.every((entry) => entry.isDirectory && entry.isSymlink)).toBe(true) + }) +}) diff --git a/src/relay/mobile-file-directory-reader.ts b/src/relay/mobile-file-directory-reader.ts new file mode 100644 index 00000000000..7db3cdf7bb6 --- /dev/null +++ b/src/relay/mobile-file-directory-reader.ts @@ -0,0 +1,76 @@ +import { opendir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { + createMobileFileDirectoryLimitState, + trackMobileFileDirectoryEntry +} from '../shared/mobile-file-directory-limit' + +type RelayDirectorySourceEntry = { + name: string + isDirectory(): boolean + isSymbolicLink(): boolean +} + +export type RelayDirectoryEntry = { + name: string + isDirectory: boolean + isSymlink: boolean +} + +const MOBILE_DIRECTORY_STAT_CONCURRENCY = 32 + +export async function isRelayDirectoryEntry( + dirPath: string, + entry: RelayDirectorySourceEntry +): Promise { + if (entry.isDirectory()) { + return true + } + if (!entry.isSymbolicLink()) { + return false + } + try { + // Why: linked workspace directories must remain expandable in the file explorer. + return (await stat(join(dirPath, entry.name))).isDirectory() + } catch { + return false + } +} + +export async function readMobileRelayDirectory(dirPath: string): Promise { + return collectMobileRelayDirectoryEntries(dirPath, await opendir(dirPath)) +} + +export async function collectMobileRelayDirectoryEntries( + dirPath: string, + directory: AsyncIterable +): Promise { + const entries: RelayDirectoryEntry[] = [] + let batch: RelayDirectorySourceEntry[] = [] + const limit = createMobileFileDirectoryLimitState() + + const flushBatch = async (): Promise => { + entries.push( + ...(await Promise.all( + batch.map(async (entry) => ({ + name: entry.name, + isDirectory: await isRelayDirectoryEntry(dirPath, entry), + isSymlink: entry.isSymbolicLink() + })) + )) + ) + batch = [] + } + + for await (const entry of directory) { + trackMobileFileDirectoryEntry(limit, entry) + batch.push(entry) + if (batch.length === MOBILE_DIRECTORY_STAT_CONCURRENCY) { + await flushBatch() + } + } + if (batch.length > 0) { + await flushBatch() + } + return entries +} diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 9b90692cd28..11348f99b2b 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdirSync, @@ -11,6 +11,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' +import * as configOverlayMirroring from '../main/pty/config-overlay-mirroring' import { PluginOverlayManager } from './plugin-overlay' import { resolvePiSourceAgentDir } from './plugin-overlay-env' @@ -70,6 +71,30 @@ describe('PluginOverlayManager', () => { expect(manager.materializeOpenCode('tab-missing:0', join(homeDir, 'missing'))).toBeNull() }) + it('rejects an over-capacity remote config before replacing the last good overlay', () => { + manager.setSources({ opencodePluginSource: 'first plugin' }) + const overlayDir = manager.materializeOpenCode('tab-capacity:0')! + const pluginPath = join(overlayDir, 'plugins', 'orca-opencode-status.js') + const userConfigDir = join(homeDir, 'large-opencode-config') + mkdirSync(userConfigDir) + const planSpy = vi + .spyOn(configOverlayMirroring, 'createConfigOverlayPlan') + .mockImplementation(() => { + throw new configOverlayMirroring.ConfigOverlayCapacityError('entries', 4_097, 4_096) + }) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + expect(manager.materializeOpenCode('tab-capacity:0', userConfigDir)).toBeNull() + expect(readFileSync(pluginPath, 'utf8')).toBe('first plugin') + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Agent config overlay entries exceeded its 4096 limit') + ) + } finally { + planSpy.mockRestore() + stderrSpy.mockRestore() + } + }) + it('installs Pi extension into the real agent extensions dir', () => { manager.setSources({ piExtensionSource: '// pi extension' }) const dir = manager.materializePi('tab-2:0') diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 15adf7004e5..6ac4e12a42f 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -17,19 +17,19 @@ // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - statSync, - unlinkSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { + applyConfigOverlayPlan, + createConfigOverlayPlan, + type ConfigOverlayPlan +} from '../main/pty/config-overlay-mirroring' +import { safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { + isManagedPiExtensionFile, + withOrcaManagedPiExtensionMarker +} from '../main/pi/managed-extension-ownership' import type { PiAgentKind } from '../shared/pi-agent-kind' const RELAY_HOOKS_DIR = '.orca-relay' @@ -41,13 +41,6 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' -const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension' - -function withOrcaManagedPiExtensionMarker(source: string): string { - return source.includes(ORCA_MANAGED_EXTENSION_MARKER) - ? source - : `// ${ORCA_MANAGED_EXTENSION_MARKER}\n${source}` -} // Why: source-dir resolution is keyed off the launching agent (Pi or OMP). // Both consume `PI_CODING_AGENT_DIR` but default to different `~/./agent` // paths on the remote disk. The renderer-chosen launch command flows in via @@ -83,6 +76,12 @@ export function getRelayPiStatusExtensionPath(agentDir: string): string { return join(agentDir, 'extensions', PI_EXTENSION_FILE) } +/** Presence of this file is what makes an overlay usable — a rebuild that failed + * after the wipe leaves the dir itself present but the plugin missing. */ +export function getRelayOpenCodePluginPath(overlayDir: string): string { + return join(overlayDir, 'plugins', OPENCODE_PLUGIN_FILE) +} + export class PluginOverlayManager { private opencodePluginSource: string | null = null private piExtensionSources: Record = { @@ -137,50 +136,16 @@ export class PluginOverlayManager { return this.piExtensionSources[kind] ?? this.piExtensionSources.pi } - private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - - if (entry.name === 'plugins') { - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - isLinkPointingToDir = false - } - } - - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - const overlayPluginsDir = join(overlayDir, 'plugins') - mkdirSync(overlayPluginsDir, { recursive: true }) - for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if (pluginEntry.name === OPENCODE_PLUGIN_FILE) { - continue - } - mirrorEntry( - join(resolvedSource, pluginEntry.name), - join(overlayPluginsDir, pluginEntry.name) - ) - } - continue - } - } - - mirrorEntry(sourcePath, join(overlayDir, entry.name)) - } - } - private writeOpenCodePlugin(overlayDir: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, OPENCODE_PLUGIN_FILE) try { unlinkSync(pluginPath) - } catch { - // Fresh overlay or no same-named stale symlink. + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } } writeFileSync(pluginPath, this.opencodePluginSource!) } @@ -197,8 +162,7 @@ export class PluginOverlayManager { } const dir = join(this.opencodeRoot, safeDirName(id)) try { - safeRemoveOverlay(dir, this.opencodeRoot) - mkdirSync(dir, { recursive: true }) + let plan: ConfigOverlayPlan | null = null if (existingConfigDir) { if (!existsSync(existingConfigDir)) { return null @@ -206,7 +170,16 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir) + plan = createConfigOverlayPlan(existingConfigDir, { + reservedPluginFile: OPENCODE_PLUGIN_FILE + }) + } + if (!safeRemoveOverlay(dir, this.opencodeRoot)) { + throw new Error('OpenCode overlay cleanup exceeded its safety limits') + } + mkdirSync(dir, { recursive: true }) + if (plan) { + applyConfigOverlayPlan(plan, dir) } this.writeOpenCodePlugin(dir) return dir @@ -222,14 +195,6 @@ export class PluginOverlayManager { return join(this.homeDir, PI_AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR) } - private canOverwritePiExtension(path: string): boolean { - try { - return readFileSync(path, 'utf8').includes(ORCA_MANAGED_EXTENSION_MARKER) - } catch { - return true - } - } - /** Install the Pi/OMP status extension into the remote real agent dir and * return that directory. `kind` selects which Pi-compatible agent's default * dir to use when `existingAgentDir` is not supplied. */ @@ -246,7 +211,7 @@ export class PluginOverlayManager { const extensionsDir = join(sourceAgentDir, 'extensions') mkdirSync(extensionsDir, { recursive: true }) const extensionPath = join(extensionsDir, PI_EXTENSION_FILE) - if (!this.canOverwritePiExtension(extensionPath)) { + if (existsSync(extensionPath) && !isManagedPiExtensionFile(extensionPath)) { return null } writeFileSync(extensionPath, extensionSource) @@ -259,10 +224,8 @@ export class PluginOverlayManager { } } - /** Drop a paneKey's overlay dirs on PTY exit. Best-effort; cleanup over a - * recursive tree may fail on exotic filesystems but the worst-case - * outcome is unbounded growth on a long-lived relay, which the per-pane - * caches alone do not bound. */ + /** Drop a paneKey's overlay dirs on PTY exit. Best-effort and bounded; an + * exotic or pathological tree is logged and left for a later cleanup. */ clearOverlay(id: string): void { if (!isUsableId(id)) { return @@ -273,7 +236,11 @@ export class PluginOverlayManager { // safeRemoveOverlay keeps each call bounded to its own tree. for (const root of [this.opencodeRoot, ...Object.values(this.piRoots)]) { try { - safeRemoveOverlay(join(root, safe), root) + if (!safeRemoveOverlay(join(root, safe), root)) { + process.stderr.write( + `[plugin-overlay] overlay cleanup stopped at its safety limit: ${join(root, safe)}\n` + ) + } } catch (err) { // Why: log the failed cleanup so a permission/IO error is observable. // The leak is the failure mode the per-pane cache eviction exists to diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts index 9df947b1aad..01f4ad7300f 100644 --- a/src/relay/port-scan-handler.test.ts +++ b/src/relay/port-scan-handler.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { parseHexAddress } from './port-scan-handler' +import { LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES } from '../shared/linux-proc-port-scan-limits' +import { parseHexAddress, parseLinuxProcListeningSockets } from './port-scan-handler' import { parseWindowsNetstatOutput, parseWindowsPowerShellPortRows } from './windows-port-scan' describe('parseHexAddress', () => { @@ -68,6 +69,20 @@ describe('parseHexAddress', () => { }) }) +describe('parseLinuxProcListeningSockets', () => { + it('caps retained listeners before inode and process scans', () => { + const rows = Array.from( + { length: LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES + 5 }, + (_, index) => + ` ${index}: 0100007F:${(index + 1).toString(16).padStart(4, '0')} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 ${index + 1}` + ) + + expect(parseLinuxProcListeningSockets(['header', ...rows].join('\n'))).toHaveLength( + LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES + ) + }) +}) + describe('parseWindowsPowerShellPortRows', () => { it('parses PowerShell JSON arrays', () => { expect( diff --git a/src/relay/port-scan-handler.ts b/src/relay/port-scan-handler.ts index a576d8921fa..f3ead301c7a 100644 --- a/src/relay/port-scan-handler.ts +++ b/src/relay/port-scan-handler.ts @@ -1,5 +1,12 @@ -import { readFile, readdir, readlink } from 'node:fs/promises' import { getProcessOutputFields } from '../shared/process-output-field-scanner' +import { mapLinuxSocketInodesToPids } from '../shared/linux-proc-socket-owner-scanner' +import { + createLinuxProcTextReadBudget, + LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES, + readLinuxProcNetworkTable, + readLinuxProcTextWithinBudget, + type LinuxProcTextReadBudget +} from '../shared/linux-proc-port-scan-limits' import type { RelayDispatcher, RequestContext } from './dispatcher' import { scanWindowsListeningPorts } from './windows-port-scan' @@ -43,16 +50,17 @@ export class PortScanHandler { this.readProcNet('/proc/net/tcp6') ]) - const listeningSockets = [...tcp4, ...tcp6] + const listeningSockets = [...tcp4, ...tcp6].slice(0, LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES) if (listeningSockets.length === 0) { return [] } const inodeSet = new Set(listeningSockets.map((s) => s.inode)) - const inodeToPid = await this.mapInodesToPids(inodeSet) + const inodeToPid = await mapLinuxSocketInodesToPids(inodeSet) const seen = new Set() const results: DetectedPort[] = [] + const metadataBudget = createLinuxProcTextReadBudget() const relayPid = process.pid const relayParentPid = process.ppid @@ -72,7 +80,7 @@ export class PortScanHandler { continue } - const processName = pid != null ? await this.getProcessName(pid) : undefined + const processName = pid != null ? await this.getProcessName(pid, metadataBudget) : undefined if (processName === 'sshd') { continue @@ -95,111 +103,49 @@ export class PortScanHandler { private async readProcNet( path: string ): Promise<{ port: number; host: string; inode: number }[]> { - let content: string - try { - content = await readFile(path, 'utf-8') - } catch { - return [] - } - - const lines = content.split('\n') - const results: { port: number; host: string; inode: number }[] = [] - - for (let i = 1; i < lines.length; i++) { - const fields = getProcessOutputFields(lines[i], 10) - if (fields.length < 10) { - continue - } - - // State field (index 3): 0A = TCP_LISTEN - if (fields[3] !== '0A') { - continue - } - - const localAddress = fields[1] - const parsed = parseHexAddress(localAddress) - if (!parsed) { - continue - } - - const inode = Number.parseInt(fields[9], 10) - if (Number.isNaN(inode) || inode === 0) { - continue - } - - results.push({ port: parsed.port, host: parsed.host, inode }) - } - - return results + const content = await readLinuxProcNetworkTable(path) + return content === null ? [] : parseLinuxProcListeningSockets(content) } - private async mapInodesToPids(inodes: Set): Promise> { - const result = new Map() - if (inodes.size === 0) { - return result - } - - let pids: string[] - try { - pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name)) - } catch { - return result - } - - for (const pidStr of pids) { - const fdDir = `/proc/${pidStr}/fd` - let fds: string[] - try { - fds = await readdir(fdDir) - } catch { - continue - } - - const pid = Number.parseInt(pidStr, 10) - - for (const fd of fds) { - let link: string - try { - link = await readlink(`${fdDir}/${fd}`) - } catch { - continue - } - - const match = link.match(/^socket:\[(\d+)\]$/) - if (!match) { - continue - } - - const inode = Number.parseInt(match[1], 10) - if (inodes.has(inode)) { - result.set(inode, pid) - } - } - } - - return result - } - - private async getProcessName(pid: number): Promise { - try { - const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf-8') - if (!cmdline) { - return undefined - } - - const exe = cmdline.split('\0')[0] - if (!exe) { - return undefined - } - - const parts = exe.split('/') - return parts.at(-1) - } catch { + private async getProcessName( + pid: number, + budget: LinuxProcTextReadBudget + ): Promise { + const cmdline = await readLinuxProcTextWithinBudget(`/proc/${pid}/cmdline`, budget) + if (!cmdline) { return undefined } + + const exe = cmdline.split('\0')[0] + return exe ? exe.split('/').at(-1) : undefined } } +export function parseLinuxProcListeningSockets( + content: string +): { port: number; host: string; inode: number }[] { + const lines = content.split('\n') + const results: { port: number; host: string; inode: number }[] = [] + + for (let i = 1; i < lines.length; i++) { + const fields = getProcessOutputFields(lines[i], 10) + if (fields.length < 10 || fields[3] !== '0A') { + continue + } + const parsed = parseHexAddress(fields[1]) + const inode = Number.parseInt(fields[9], 10) + if (!parsed || !Number.isFinite(inode) || inode === 0) { + continue + } + results.push({ port: parsed.port, host: parsed.host, inode }) + if (results.length >= LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES) { + break + } + } + + return results +} + // Why: /proc/net/tcp encodes addresses as hex pairs in host-byte-order. // IPv4: 8 hex chars for address + ':' + 4 hex chars for port. // IPv6: 32 hex chars for address + ':' + 4 hex chars for port. diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 80bc2ba490a..9729ea14192 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -32,7 +32,8 @@ import { buildCommandLookupSpecs, hasAbsoluteCommandPath, isCommandOnPathForRelay, - PreflightHandler + PreflightHandler, + RELAY_AGENT_PATH_PROBE_CONCURRENCY } from './preflight-handler' function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { @@ -233,6 +234,54 @@ describe('hasAbsoluteCommandPath', () => { }) describe('PreflightHandler', () => { + it.each([ + ['at the limit', RELAY_AGENT_PATH_PROBE_CONCURRENCY], + ['above the limit', RELAY_AGENT_PATH_PROBE_CONCURRENCY + 1] + ])('bounds relay-supplied command probes %s', async (_, count) => { + let active = 0 + let peak = 0 + const releases: (() => void)[] = [] + execFileAsyncMock.mockImplementation(async (_file, args) => { + active++ + peak = Math.max(peak, active) + await new Promise((resolve) => releases.push(resolve)) + active-- + const command = String(args[1]).match(/command -v '([^']+)'/)?.[1] ?? 'agent' + return { stdout: `__ORCA_AGENT_PATH__/relay/path/${command}\n` } + }) + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + new PreflightHandler(dispatcher as never) + + const detection = requestHandlers.get('preflight.detectAgents')!({ + commands: Array.from({ length: count }, (_, index) => ({ + id: `agent-${index}`, + cmd: `agent-${index}` + })) + }) + await vi.waitFor(() => + expect(execFileAsyncMock).toHaveBeenCalledTimes( + Math.min(count, RELAY_AGENT_PATH_PROBE_CONCURRENCY) + ) + ) + if (count > RELAY_AGENT_PATH_PROBE_CONCURRENCY) { + releases.shift()?.() + await vi.waitFor(() => expect(execFileAsyncMock).toHaveBeenCalledTimes(count)) + } + releases.splice(0).forEach((release) => release()) + + await expect(detection).resolves.toMatchObject({ + agents: expect.arrayContaining(Array.from({ length: count }, (_, index) => `agent-${index}`)) + }) + expect(peak).toBe(Math.min(count, RELAY_AGENT_PATH_PROBE_CONCURRENCY)) + }) + it('honors required commands when reporting detected agents', async () => { execFileAsyncMock.mockImplementation(async (_file, args) => { const script = String(args[1]) diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 101c796137c..d3505614f0d 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -8,6 +8,7 @@ import { isPwshAvailable } from '../main/pwsh' import { isWslAvailable, listWslDistros } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' +import { mapWithConcurrency } from '../shared/map-with-concurrency' const execFileAsync = promisify(execFile) @@ -35,6 +36,7 @@ type AgentDetectionCommand = { const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish']) const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin']) const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__' +export const RELAY_AGENT_PATH_PROBE_CONCURRENCY = 8 export class PreflightHandler { private dispatcher: RelayDispatcher @@ -67,11 +69,13 @@ export class PreflightHandler { ) ] - const results = await Promise.all( - probeCommands.map(async (cmd) => ({ + const results = await mapWithConcurrency( + probeCommands, + RELAY_AGENT_PATH_PROBE_CONCURRENCY, + async (cmd) => ({ cmd, installed: await this.isCommandOnPath(cmd) - })) + }) ) const foundCommands = new Set( results.filter((result) => result.installed).map(({ cmd }) => cmd) diff --git a/src/relay/protocol-handshake.test.ts b/src/relay/protocol-handshake.test.ts index 822fea3e20b..8cfaaea92c5 100644 --- a/src/relay/protocol-handshake.test.ts +++ b/src/relay/protocol-handshake.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { MessageType, HEADER_LENGTH, + MAX_BUFFERED_FRAME_CHUNKS, FrameDecoder, encodeHandshakeFrame, parseHandshakeMessage, @@ -9,6 +10,45 @@ import { } from './protocol' describe('handshake framing', () => { + it('bounds retained chunk wrappers for a byte-fragmented frame', () => { + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((frame) => frames.push(frame)) + const payloadLength = MAX_BUFFERED_FRAME_CHUNKS + 512 + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = MessageType.Regular + header.writeUInt32BE(payloadLength, 9) + decoder.feed(header) + + const byte = Buffer.from('x') + const state = decoder as unknown as { chunks: Buffer[] } + for (let index = 0; index < payloadLength; index += 1) { + decoder.feed(byte) + expect(state.chunks.length).toBeLessThanOrEqual(MAX_BUFFERED_FRAME_CHUNKS) + } + + expect(frames).toHaveLength(1) + expect(frames[0].payload.equals(Buffer.alloc(payloadLength, 0x78))).toBe(true) + }) + + it('does not coalesce ordinary transport chunks while a frame is incomplete', () => { + const decoder = new FrameDecoder(() => {}) + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = MessageType.Regular + header.writeUInt32BE(1024 * 1024, 9) + decoder.feed(header) + const ordinaryChunks = Array.from({ length: 16 }, () => Buffer.alloc(32 * 1024)) + + for (const chunk of ordinaryChunks) { + decoder.feed(chunk) + } + + const state = decoder as unknown as { chunks: Buffer[] } + expect(state.chunks).toHaveLength(ordinaryChunks.length + 1) + for (const [index, chunk] of ordinaryChunks.entries()) { + expect(state.chunks[index + 1]).toBe(chunk) + } + }) + it('round-trips an orca-relay-handshake envelope through the existing framing', () => { const sent = encodeHandshakeFrame({ type: 'orca-relay-handshake', @@ -65,4 +105,31 @@ describe('handshake framing', () => { expect(MessageType.Handshake).not.toBe(MessageType.Regular) expect(MessageType.Handshake).not.toBe(MessageType.KeepAlive) }) + + it('stream-discards an oversized payload instead of buffering toward its advertised size', () => { + const errors: Error[] = [] + const decoder = new FrameDecoder( + () => {}, + (error) => errors.push(error) + ) + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = MessageType.Regular + header.writeUInt32BE(0xffffffff, 9) + + decoder.feed(header) + const state = decoder as unknown as { + bufferedLength: number + oversizedPayloadBytesRemaining: number + } + expect(errors).toHaveLength(1) + expect(state.bufferedLength).toBe(0) + + const payloadChunk = Buffer.alloc(64 * 1024) + for (let index = 0; index < 128; index += 1) { + decoder.feed(payloadChunk) + expect(state.bufferedLength).toBe(0) + } + expect(state.oversizedPayloadBytesRemaining).toBe(0xffffffff - 128 * payloadChunk.length) + expect(errors).toHaveLength(1) + }) }) diff --git a/src/relay/protocol-json-encoding-memory.test.ts b/src/relay/protocol-json-encoding-memory.test.ts new file mode 100644 index 00000000000..aed0fca223b --- /dev/null +++ b/src/relay/protocol-json-encoding-memory.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import { + encodeJsonRpcFrame, + HEADER_LENGTH, + MAX_MESSAGE_SIZE, + parseJsonRpcMessage +} from './protocol' +import { RELAY_JSON_MAX_STRUCTURAL_TOKENS } from '../shared/relay-json-admission' + +describe('relay JSON frame encoding memory', () => { + it('preserves accepted JSON bytes', () => { + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: { escaped: 'line\n🐋', omitted: undefined } + } + + const frame = encodeJsonRpcFrame(message, 7, 3) + + expect(frame.subarray(HEADER_LENGTH).toString('utf8')).toBe(JSON.stringify(message)) + }) + + it('rejects an oversized message before allocating its payload Buffer', () => { + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: '\n'.repeat(MAX_MESSAGE_SIZE) + } + const fromSpy = vi.spyOn(Buffer, 'from') + + try { + expect(() => encodeJsonRpcFrame(message, 1, 0)).toThrow('Message too large') + expect(fromSpy).not.toHaveBeenCalled() + } finally { + fromSpy.mockRestore() + } + }) + + it('rejects structurally amplified inbound JSON before parsing', () => { + const payload = Buffer.from( + `{"jsonrpc":"2.0","id":1,"method":"x","params":{"values":[${'0,'.repeat( + RELAY_JSON_MAX_STRUCTURAL_TOKENS + )}0]}}` + ) + const parseSpy = vi.spyOn(JSON, 'parse') + try { + expect(() => parseJsonRpcMessage(payload)).toThrow(/JSON structure exceeds/) + expect(parseSpy).not.toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } + }) +}) diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 4df0f7bd9b5..018364f2789 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -1,11 +1,19 @@ // Self-contained relay protocol — mirrors src/main/ssh/relay-protocol.ts // but has no Electron dependencies. Deployed standalone to remote hosts. +import { + JsonStringifyByteLimitError, + stringifyJsonWithinByteLimit +} from '../shared/memory-safety/node-bounded-json-stringify' +import { parseRelayJsonText } from '../shared/relay-json-admission' + export const RELAY_VERSION = '0.1.0' export const RELAY_SENTINEL = `ORCA-RELAY v${RELAY_VERSION} READY\n` export const HEADER_LENGTH = 13 export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024 +export const MAX_BUFFERED_FRAME_CHUNKS = 4_096 +const MAX_COALESCED_TINY_FRAME_CHUNK_BYTES = 8 * 1024 export const MessageType = { Regular: 1, @@ -28,7 +36,7 @@ export function encodeHandshakeFrame(msg: HandshakeMessage): Buffer { } export function parseHandshakeMessage(payload: Buffer): HandshakeMessage { - const msg = JSON.parse(payload.toString('utf-8')) as HandshakeMessage + const msg = parseRelayJsonText(payload.toString('utf-8')) const t = (msg as { type?: string }).type if ( t !== 'orca-relay-handshake' && @@ -73,6 +81,11 @@ export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024 * the client reassembles by concatenation (it does not depend on chunk size). */ export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024 +/** Bounds retained serialized Git results while preserving responses far above + * the per-file render limit and git.exec's output ceiling. */ +export const MAX_GIT_RESPONSE_STREAM_BYTES = 64 * 1024 * 1024 +export const MAX_GIT_RESPONSE_STREAM_CHUNKS = 4_096 + /** Sentinel result returned in place of a large git response: the real payload * follows as git.responseChunk frames on the bulk lane. Old relays never emit * this, so a new client falls back to the plain result they return. */ @@ -82,7 +95,8 @@ export type GitResponseStreamMarker = { export const RelayErrorCode = { TooManyStreams: -33006, - StreamProtocolError: -33007 + StreamProtocolError: -33007, + MarkdownDocumentListingCapacity: -33008 } as const export type JsonRpcRequest = { @@ -129,10 +143,16 @@ export function encodeFrame( } export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer { - const payload = Buffer.from(JSON.stringify(msg), 'utf-8') - if (payload.length > MAX_MESSAGE_SIZE) { - throw new Error(`Message too large: ${payload.length} bytes`) + let serialized: string + try { + serialized = stringifyJsonWithinByteLimit(msg, MAX_MESSAGE_SIZE).serialized + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + throw new Error(`Message too large: more than ${MAX_MESSAGE_SIZE} bytes`) + } + throw error } + const payload = Buffer.from(serialized, 'utf-8') return encodeFrame(MessageType.Regular, id, ack, payload) } @@ -147,6 +167,7 @@ export class FrameDecoder { // frame exactly once instead. private chunks: Buffer[] = [] private bufferedLength = 0 + private oversizedPayloadBytesRemaining = 0 private onFrame: (frame: DecodedFrame) => void private onError: ((err: Error) => void) | null @@ -156,12 +177,18 @@ export class FrameDecoder { } feed(chunk: Buffer | Uint8Array): void { - const buf = Buffer.isBuffer(chunk) + let buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + if (this.oversizedPayloadBytesRemaining > 0 && buf.length > 0) { + const discarded = Math.min(this.oversizedPayloadBytesRemaining, buf.length) + this.oversizedPayloadBytesRemaining -= discarded + buf = buf.subarray(discarded) + } if (buf.length > 0) { this.chunks.push(buf) this.bufferedLength += buf.length + this.coalesceBufferedChunks() } while (this.bufferedLength >= HEADER_LENGTH) { @@ -169,28 +196,28 @@ export class FrameDecoder { const length = header.readUInt32BE(9) const totalLength = HEADER_LENGTH + length - if (this.bufferedLength < totalLength) { - // Not fully received yet (also holds oversized frames until they can - // be skipped whole, keeping the decoder synchronized). - break - } - if (length > MAX_MESSAGE_SIZE) { - // Why: Throwing here would leave the buffer in a partially consumed - // state — subsequent feed() calls would try to parse the leftover - // payload bytes as a new header, corrupting every future frame. - // Instead we skip the entire oversized frame so the decoder stays - // synchronized with the stream. - this.discardBytes(totalLength) + // Why: retain only the header, then stream-discard the advertised + // payload so a 13-byte malicious header cannot pin up to 4 GiB. + this.discardBytes(HEADER_LENGTH) + this.oversizedPayloadBytesRemaining = length + this.discardBufferedOversizedPayload() const err = new Error(`Frame payload too large: ${length} bytes — discarded`) if (this.onError) { this.onError(err) } else { process.stderr.write(`[relay] ${err.message}\n`) } + if (this.oversizedPayloadBytesRemaining > 0) { + break + } continue } + if (this.bufferedLength < totalLength) { + break + } + const framed = this.takeBytes(totalLength) const frame: DecodedFrame = { type: framed[0], @@ -205,6 +232,7 @@ export class FrameDecoder { reset(): void { this.chunks = [] this.bufferedLength = 0 + this.oversizedPayloadBytesRemaining = 0 } // Why: at the handshake → dispatcher transition, the next consumer must @@ -280,11 +308,37 @@ export class FrameDecoder { } this.bufferedLength -= count } + + private discardBufferedOversizedPayload(): void { + const discarded = Math.min(this.oversizedPayloadBytesRemaining, this.bufferedLength) + if (discarded === 0) { + return + } + this.discardBytes(discarded) + this.oversizedPayloadBytesRemaining -= discarded + } + + private coalesceBufferedChunks(): void { + // Why: merge adversarial tiny fragments without copying ordinary transport chunks. + while (this.chunks.length >= 2) { + const right = this.chunks.at(-1)! + const left = this.chunks.at(-2)! + if ( + left.length > right.length || + left.length + right.length > MAX_COALESCED_TINY_FRAME_CHUNK_BYTES + ) { + break + } + this.chunks.splice(-2, 2, Buffer.concat([left, right], left.length + right.length)) + } + if (this.chunks.length > MAX_BUFFERED_FRAME_CHUNKS) { + this.chunks = [Buffer.concat(this.chunks, this.bufferedLength)] + } + } } export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { - const text = payload.toString('utf-8') - const msg = JSON.parse(text) as JsonRpcMessage + const msg = parseRelayJsonText(payload.toString('utf-8')) if (msg.jsonrpc !== '2.0') { throw new Error(`Invalid JSON-RPC version: ${(msg as Record).jsonrpc}`) } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 3e3bbebd9fd..5369a1e80b0 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -11,6 +11,7 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../shared/setup-agent-sequencing' import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress' +import { MAX_TERMINAL_COLS } from '../shared/terminal-size-limits' const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -24,7 +25,9 @@ const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ write: vi.fn(), resize: vi.fn(), kill: vi.fn(), - clear: vi.fn() + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() } })) @@ -38,9 +41,11 @@ import { PtyHandler, attachIdentityMismatches } from './pty-handler' -import type { RelayDispatcher } from './dispatcher' +import { MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES } from './pty-persistence-envelope' +import type { RelayDispatcher, RelayNotificationWriteResult } from './dispatcher' type TestRequestContext = { + clientId?: number isStale: () => boolean signal?: AbortSignal } @@ -50,8 +55,18 @@ function createMockDispatcher() { string, (params: Record, context?: TestRequestContext) => Promise >() - const notificationHandlers = new Map) => void>() + const notificationHandlers = new Map< + string, + (params: Record, context: TestRequestContext) => void + >() const notifications: { method: string; params?: Record }[] = [] + const targetedNotifications: { + clientId: number + method: string + params?: Record + }[] = [] + const clientDetachListeners = new Set<(clientId: number) => void>() + const connectedClientIds = new Set([1]) const dispatcher = { onRequest: vi.fn( @@ -62,33 +77,75 @@ function createMockDispatcher() { requestHandlers.set(method, handler) } ), - onNotification: vi.fn((method: string, handler: (params: Record) => void) => { - notificationHandlers.set(method, handler) + onNotification: vi.fn( + ( + method: string, + handler: (params: Record, context: TestRequestContext) => void + ) => { + notificationHandlers.set(method, handler) + } + ), + onClientDetached: vi.fn((listener: (clientId: number) => void) => { + clientDetachListeners.add(listener) + return () => clientDetachListeners.delete(listener) + }), + connectedClientIds: vi.fn(() => Array.from(connectedClientIds)), + evictClient: vi.fn((clientId: number) => { + connectedClientIds.delete(clientId) + for (const listener of clientDetachListeners) { + listener(clientId) + } }), notify: vi.fn((method: string, params?: Record) => { notifications.push({ method, params }) }), + notifyClientWithBackpressure: vi.fn( + ( + clientId: number, + method: string, + params?: Record + ): RelayNotificationWriteResult => { + targetedNotifications.push({ clientId, method, params }) + const { deliveryToken: _deliveryToken, ...legacyParams } = params ?? {} + dispatcher.notify(method, legacyParams) + return { delivered: true, saturated: false, drained: Promise.resolve() } + } + ), // Helpers for tests _requestHandlers: requestHandlers, _notificationHandlers: notificationHandlers, _notifications: notifications, + _targetedNotifications: targetedNotifications, async callRequest( method: string, params: Record = {}, - context?: TestRequestContext + context: TestRequestContext = { clientId: 1, isStale: () => false } ) { const handler = requestHandlers.get(method) if (!handler) { throw new Error(`No handler for ${method}`) } + if (typeof context?.clientId === 'number') { + connectedClientIds.add(context.clientId) + } return handler(params, context) }, - callNotification(method: string, params: Record = {}) { + callNotification( + method: string, + params: Record = {}, + context: TestRequestContext = { clientId: 1, isStale: () => false } + ) { const handler = notificationHandlers.get(method) if (!handler) { throw new Error(`No handler for ${method}`) } - handler(params) + handler(params, context) + }, + emitClientDetached(clientId: number) { + connectedClientIds.delete(clientId) + for (const listener of clientDetachListeners) { + listener(clientId) + } } } @@ -126,6 +183,8 @@ describe('PtyHandler', () => { mockPtyInstance.resize.mockReset() mockPtyInstance.kill.mockReset() mockPtyInstance.clear.mockReset() + mockPtyInstance.pause.mockReset() + mockPtyInstance.resume.mockReset() mockPtySpawn.mockReturnValue({ ...mockPtyInstance }) @@ -209,6 +268,13 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('rejects oversized spawn dimensions before native allocation', async () => { + await expect( + dispatcher.callRequest('pty.spawn', { cols: MAX_TERMINAL_COLS + 1, rows: 24 }) + ).rejects.toThrow(`1 through ${MAX_TERMINAL_COLS}`) + expect(mockPtySpawn).not.toHaveBeenCalled() + }) + it("does not forward Orca's own NODE_ENV into the spawned shell", async () => { // Why: NODE_ENV in the relay host process is a build-mode flag, not the // user's; leaking it breaks `next build` and Vitest in the terminal. @@ -414,6 +480,25 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS) }) + it('rejects oversized retained spawn fields before creating a native PTY', async () => { + await expect( + dispatcher.callRequest('pty.spawn', { + cwd: 'x'.repeat(MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES + 1) + }) + ).rejects.toThrow(`exceeds ${MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES} bytes`) + + expect(mockPtySpawn).not.toHaveBeenCalled() + expect(handler.activePtyCount).toBe(0) + }) + + it('bounds the requested PTY serialization id list', async () => { + await expect( + dispatcher.callRequest('pty.serialize', { + ids: Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, (_, index) => `pty-${index}`) + }) + ).rejects.toThrow(`PTY persistence request exceeds ${MAX_RELAY_PTY_SESSIONS} entries`) + }) + it('spawns a PTY without post-Node-18 array copy methods', async () => { const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toReversed') Reflect.deleteProperty(Array.prototype, 'toReversed') @@ -2338,7 +2423,7 @@ describe('PtyHandler', () => { expect(mockPtySpawn).not.toHaveBeenCalled() }) - it('applies the physical PTY cap to untrusted revive state', async () => { + it('rejects untrusted revive state above the physical PTY cap transactionally', async () => { const state = JSON.stringify( Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, (_, index) => ({ id: `pty-${index + 1}`, @@ -2349,17 +2434,11 @@ describe('PtyHandler', () => { worktreeId: 'repo-id::/repo' })) ) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await expect(dispatcher.callRequest('pty.revive', { state })).rejects.toThrow( - 'Maximum number of PTY sessions reached (50)' - ) - } finally { - killSpy.mockRestore() - } - - expect(mockPtySpawn).toHaveBeenCalledTimes(MAX_RELAY_PTY_SESSIONS) - expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS) + await expect(dispatcher.callRequest('pty.revive', { state })).rejects.toThrow( + `PTY persistence state exceeds ${MAX_RELAY_PTY_SESSIONS} entries` + ) + expect(mockPtySpawn).not.toHaveBeenCalled() + expect(handler.activePtyCount).toBe(0) }) it('deduplicates concurrent revive requests for the same physical PTY id', async () => { @@ -2881,6 +2960,40 @@ describe('PtyHandler', () => { expect(vi.getTimerCount()).toBe(0) }) + it('keeps output delivery registered when relay disposal cannot kill a live PTY', async () => { + let onDataCb: ((data: string) => void) | undefined + let rejectKill = true + const mockKill = vi.fn(() => { + if (rejectKill) { + throw new Error('persistent dispose kill failure') + } + }) + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn((cb: (data: string) => void) => { + onDataCb = cb + }) + }) + + await dispatcher.callRequest('pty.spawn', {}) + const dispose = handler.dispose({ waitForPhysicalExit: false }) + const rejected = expect(dispose).rejects.toThrow('persistent dispose kill failure') + await vi.advanceTimersByTimeAsync(250) + await rejected + + onDataCb?.('still alive') + await vi.advanceTimersByTimeAsync(8) + expect(dispatcher._notifications).toContainEqual({ + method: 'pty.data', + params: { id: 'pty-1', data: 'still alive' } + }) + + rejectKill = false + await handler.dispose({ waitForPhysicalExit: false }) + expect(handler.activePtyCount).toBe(0) + }) + it('takes ownership when dispose overlaps a queued graceful force-kill retry', async () => { let onExitCb: ((evt: { exitCode: number }) => void) | undefined let forceAttempts = 0 diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index ce2059e30aa..361cc3eecca 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -59,6 +59,25 @@ import { isAgentSessionSurfaceBinding, type AgentSessionOwnerBinding } from '../shared/agent-session-host-authority' +import { PtyOutputBroadcast } from './pty-output-broadcast' +import { + assertRelayPtyPersistenceFieldWithinLimit, + assertRelayPtyRetainedFieldsWithinLimits, + parseRelayPtyPersistenceEnvelope, + parseRelayPtyPersistenceIds, + sanitizeRelayPtyEnvToDelete, + serializeRelayPtyPersistenceEnvelope, + type RelayPtyIdentity, + type RelayPtyPersistenceEntry +} from './pty-persistence-envelope' +import { + MAX_TERMINAL_COLS, + MAX_TERMINAL_ROWS, + normalizeTerminalSize, + terminalSizeAdmissionError +} from '../shared/terminal-size-limits' + +export { PTY_OUTPUT_HIGH_WATER_CHARS, PTY_OUTPUT_LOW_WATER_CHARS } from './pty-output-broadcast' function isMissingNodePtyNativeBinding(error: unknown): boolean { return ( @@ -110,13 +129,6 @@ const AGENT_SESSION_CREATE_OPERATION_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/ const AGENT_SESSION_CREATE_OPERATION_RETENTION_MS = 24 * 60 * 60 * 1000 const AGENT_SESSION_CREATE_OPERATION_LIMIT = 4_096 -type PendingPtyOutput = { - data: string - rawLength?: number - transformed?: boolean - seq?: number -} - type ManagedStartupCommand = { command: string delivered: boolean @@ -173,13 +185,9 @@ const DEFAULT_GRACE_TIME_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 export const IMMEDIATE_PTY_EXIT_TIMEOUT_MS = 8_000 export const MAX_RELAY_PTY_SESSIONS = 50 export const REPLAY_BUFFER_MAX = 100 * 1024 -const PTY_OUTPUT_BATCH_INTERVAL_MS = 8 -const PTY_OUTPUT_DRAIN_CONTINUE_MS = 1 -const PTY_OUTPUT_FLUSH_CHUNK_CHARS = 16 * 1024 -const PTY_OUTPUT_FLUSH_MAX_WRITES = 2 const INTERACTIVE_OUTPUT_WINDOW_MS = 100 const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 -const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS +const INTERACTIVE_REDRAW_MAX_CHARS = 16 * 1024 const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 const STARTUP_COMMAND_WRITE_DELAY_MS = 50 const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500 @@ -233,35 +241,9 @@ type PtyProcessSummary = { agentSessionOwners?: AgentSessionOwnerBinding[] } -type SerializedPtyEntry = { - id: string - pid: number - cols: number - rows: number - cwd: string - paneKey?: string - tabId?: string - attachIdentity?: PtyIdentity - worktreeId?: string - terminalHandle?: string - explicitTerm?: string - envToDelete?: string[] - /** Optional for state serialized by relays predating the credential guard. */ - gitCredentialPromptGuarded?: boolean - agentSessionOwners?: AgentSessionOwnerBinding[] -} - -function sanitizeEnvToDelete(value: unknown): string[] { - return Array.isArray(value) - ? value - .filter((key): key is string => typeof key === 'string' && key.length > 0) - .slice(0, 1_024) - : [] -} - export type PtyExitListener = (event: { id: string; paneKey?: string }) => void -type PtyIdentity = { paneKey?: string; tabId?: string } +type PtyIdentity = RelayPtyIdentity /** * True when a reattach's expected pane identity contradicts the target PTY's own. @@ -292,10 +274,9 @@ export class PtyHandler { private ptys = new Map() private nextId = 1 private dispatcher: RelayDispatcher + private readonly outputBroadcast: PtyOutputBroadcast private graceTimeMs: number private graceTimer: ReturnType | null = null - private outputFlushTimer: ReturnType | null = null - private pendingOutputByPty = new Map() private lastInputAtByPty = new Map() private interactiveOutputCharsByPty = new Map() private pendingSpawnCount = 0 @@ -319,6 +300,7 @@ export class PtyHandler { constructor(dispatcher: RelayDispatcher, graceTimeMs = DEFAULT_GRACE_TIME_MS) { this.dispatcher = dispatcher + this.outputBroadcast = new PtyOutputBroadcast(dispatcher) this.graceTimeMs = graceTimeMs this.registerHandlers() } @@ -523,6 +505,7 @@ export class PtyHandler { private wireAndStore(managed: ManagedPty): void { managed.physicalExit = new PhysicalExitTracker() this.ptys.set(managed.id, managed) + this.outputBroadcast.register(managed.id, managed.pty) const emitIngressData = (emission: PtyIngressEmission): void => { const rawLength = emission.rawEndSeq - emission.rawStartSeq this.appendReplayBuffer(managed, emission.data) @@ -567,7 +550,7 @@ export class PtyHandler { } this.clearStartupCommandTimer(managed) this.releaseRelayIngress(managed) - this.flushPtyOutput(managed.id) + this.outputBroadcast.flushForExit(managed.id) this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode, @@ -612,7 +595,7 @@ export class PtyHandler { private registerHandlers(): void { this.dispatcher.onRequest('pty.spawn', (p, context) => this.spawn(p, context)) - this.dispatcher.onRequest('pty.attach', (p) => this.attach(p)) + this.dispatcher.onRequest('pty.attach', (p, context) => this.attach(p, context)) this.dispatcher.onRequest('pty.shutdown', (p) => this.shutdown(p)) this.dispatcher.onRequest('pty.sendSignal', (p) => this.sendSignal(p)) this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p)) @@ -638,9 +621,9 @@ export class PtyHandler { this.dispatcher.onNotification('pty.data', (p) => this.writeData(p)) this.dispatcher.onNotification('pty.resize', (p) => this.resize(p)) - this.dispatcher.onNotification('pty.ackData', (_p) => { - /* flow control ack -- not yet enforced */ - }) + this.dispatcher.onNotification('pty.ackData', (p, context) => + this.outputBroadcast.acknowledge(p, context) + ) } private isLikelyInteractiveRedraw(data: string): boolean { @@ -686,105 +669,21 @@ export class PtyHandler { data: string, meta: { rawLength?: number; transformed?: boolean; seq?: number } = {} ): void { - const existing = this.pendingOutputByPty.get(id) - if (meta.transformed === true) { - // Why: transformed spans lack a raw-to-clean slice mapping, so they can't be folded into the output batch. - if (existing) { - this.flushPtyOutput(id) - } - this.dispatcher.notify('pty.data', { id, data, ...meta }) + const managed = this.ptys.get(id) + if (!managed || managed.disposed) { return } - const pending: PendingPtyOutput = { data: (existing?.data ?? '') + data } - if (existing?.rawLength !== undefined || meta.rawLength !== undefined) { - pending.rawLength = - (existing?.rawLength ?? existing?.data.length ?? 0) + (meta.rawLength ?? data.length) - } - if (meta.seq !== undefined) { - pending.seq = meta.seq - } - if (this.shouldSendInteractiveOutputNow(id, pending.data)) { - this.pendingOutputByPty.delete(id) - this.clearOutputFlushTimerIfIdle() - // Why: send interactive echo immediately — batching must not add visible input delay for TUIs. - this.dispatcher.notify('pty.data', { id, ...pending }) - return - } - this.pendingOutputByPty.set(id, pending) - this.scheduleOutputFlush(PTY_OUTPUT_BATCH_INTERVAL_MS) - } - - private scheduleOutputFlush(delayMs: number): void { - if (this.outputFlushTimer !== null) { - return - } - this.outputFlushTimer = setTimeout(() => this.flushPendingOutput(), delayMs) - } - - private flushPendingOutput(): void { - this.outputFlushTimer = null - let writes = 0 - for (const [id, pending] of Array.from(this.pendingOutputByPty.entries())) { - if (writes >= PTY_OUTPUT_FLUSH_MAX_WRITES) { - break - } - this.pendingOutputByPty.delete(id) - const chunk = pending.transformed - ? pending.data - : pending.data.slice(0, PTY_OUTPUT_FLUSH_CHUNK_CHARS) - const remaining = pending.transformed ? '' : pending.data.slice(PTY_OUTPUT_FLUSH_CHUNK_CHARS) - if (remaining) { - this.pendingOutputByPty.set(id, { - data: remaining, - ...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }), - seq: pending.seq - }) - } - const chunkRawLength = pending.transformed - ? pending.rawLength - : pending.rawLength === undefined - ? undefined - : chunk.length - const chunkSeq = - pending.seq === undefined ? undefined : pending.seq - (pending.data.length - chunk.length) - this.dispatcher.notify('pty.data', { - id, - data: chunk, - ...(chunkSeq === undefined ? {} : { seq: chunkSeq }), - ...(chunkRawLength === undefined ? {} : { rawLength: chunkRawLength }), - ...(pending.transformed ? { transformed: true } : {}) - }) - writes++ - } - if (this.pendingOutputByPty.size > 0 && writes > 0) { - // Why: yield between slices of a large chunk so client input and control frames can interleave. - this.scheduleOutputFlush(PTY_OUTPUT_DRAIN_CONTINUE_MS) - } - } - - private flushPtyOutput(id: string): void { - const pending = this.pendingOutputByPty.get(id) - if (!pending) { - return - } - this.pendingOutputByPty.delete(id) - this.dispatcher.notify('pty.data', { id, ...pending }) - this.clearOutputFlushTimerIfIdle() - } - - private clearOutputFlushTimerIfIdle(): void { - if (this.pendingOutputByPty.size > 0 || this.outputFlushTimer === null) { - return - } - clearTimeout(this.outputFlushTimer) - this.outputFlushTimer = null + this.outputBroadcast.enqueue( + id, + { data, ...meta }, + meta.transformed === true || this.shouldSendInteractiveOutputNow(id, data) + ) } private clearPtyFlowState(id: string): void { - this.pendingOutputByPty.delete(id) + this.outputBroadcast.unregister(id) this.lastInputAtByPty.delete(id) this.interactiveOutputCharsByPty.delete(id) - this.clearOutputFlushTimerIfIdle() } private beginPtyCreation(operationPaths: readonly (string | undefined)[]): () => void { @@ -901,9 +800,13 @@ export class PtyHandler { context?: RequestContext ): Promise { const env = params.env as Record | undefined - const worktreeId = env?.ORCA_WORKTREE_ID + const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined + if (worktreeId !== undefined) { + assertRelayPtyPersistenceFieldWithinLimit('worktreeId', worktreeId) + } const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined const cwd = typeof params.cwd === 'string' ? params.cwd : resolveDefaultCwd() + assertRelayPtyPersistenceFieldWithinLimit('cwd', cwd) const finishCreation = this.beginPtyCreation([worktreePath, cwd]) let physicalSpawnCommitted = false const markPhysicalSpawnCommitted = (): void => { @@ -989,16 +892,21 @@ export class PtyHandler { context?: RequestContext, onPhysicalSpawnCommitted?: () => void ): Promise<{ id: string; incarnationId: string }> { + const sizeError = terminalSizeAdmissionError(params.cols, params.rows, 'pty.spawn', { + allowMissing: true + }) + if (sizeError) { + throw new Error(sizeError) + } const pty = await this.loadPty() if (!pty) { throw new Error('node-pty is not available on this remote host') } - const cols = (params.cols as number) || 80 - const rows = (params.rows as number) || 24 + const { cols, rows } = normalizeTerminalSize(params.cols, params.rows) const cwd = (params.cwd as string) || resolveDefaultCwd() const env = params.env as Record | undefined - const envToDelete = sanitizeEnvToDelete(params.envToDelete) + const envToDelete = sanitizeRelayPtyEnvToDelete(params.envToDelete) const explicitTerm = !envToDelete.includes('TERM') && env && @@ -1021,6 +929,23 @@ export class PtyHandler { // Why: kept so a restarted runtime can re-adopt this PTY under its original handle (survives revive). const terminalHandle = typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined + const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined + const attachIdentity = { + paneKey: typeof params.paneKey === 'string' ? params.paneKey : paneKey, + tabId: typeof params.tabId === 'string' ? params.tabId : tabId + } + const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined + assertRelayPtyRetainedFieldsWithinLimits({ + id, + cwd, + paneKey, + tabId, + attachIdentity, + worktreeId, + terminalHandle, + explicitTerm, + envToDelete + }) const command = typeof params.command === 'string' ? params.command : undefined const terminalWindowsWslDistro = typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null @@ -1079,12 +1004,6 @@ export class PtyHandler { onPhysicalSpawnCommitted?.() // Why: capture paneKey so the exit listener can evict per-pane caches without a separate ptyId→paneKey map. - const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined - const attachIdentity = { - paneKey: typeof params.paneKey === 'string' ? params.paneKey : paneKey, - tabId: typeof params.tabId === 'string' ? params.tabId : tabId - } - const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined const startupIngressIntent = params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION ? parsePtyStartupIngressIntent(params.startupIngress) @@ -1143,7 +1062,8 @@ export class PtyHandler { } private async attach( - params: Record + params: Record, + context?: RequestContext ): Promise<{ incarnationId: string; replay?: string }> { const id = params.id as string const managed = this.ptys.get(id) @@ -1156,7 +1076,7 @@ export class PtyHandler { if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) { managed.physicalExit?.markExited() this.releaseRelayIngress(managed) - this.flushPtyOutput(id) + this.outputBroadcast.flushForExit(id) this.notifyExitListener(managed) this.agentSessionOwners.release(managed.id) disposeManagedPty(managed) @@ -1177,14 +1097,14 @@ export class PtyHandler { throw new Error(`PTY "${id}" not found (identity mismatch)`) } + if (typeof context?.clientId === 'number') { + this.outputBroadcast.resetClient(id, context.clientId) + } managed.startupIngress?.snapshotBarrier() // Why: renderer hasn't registered replay handlers yet during spawn, so return to the caller instead of notifying too early. // Why: buffer intentionally NOT cleared after replay (client clears xterm first) so later restarts still replay full history. if (managed.buffered) { - // Why: drop pending batched bytes already in the replay buffer so attach doesn't render them twice. - this.pendingOutputByPty.delete(id) - this.clearOutputFlushTimerIfIdle() if (params.suppressReplayNotification) { return { incarnationId: managed.incarnationId, replay: managed.buffered } } @@ -1209,8 +1129,8 @@ export class PtyHandler { private resize(params: Record): void { const id = params.id as string - const cols = Math.max(1, Math.min(500, Math.floor(Number(params.cols) || 80))) - const rows = Math.max(1, Math.min(500, Math.floor(Number(params.rows) || 24))) + const cols = Math.max(1, Math.min(MAX_TERMINAL_COLS, Math.floor(Number(params.cols) || 80))) + const rows = Math.max(1, Math.min(MAX_TERMINAL_ROWS, Math.floor(Number(params.rows) || 24))) const managed = this.ptys.get(id) if (managed && !managed.disposed) { managed.pty.resize(cols, rows) @@ -1237,7 +1157,6 @@ export class PtyHandler { if (immediate) { this.releaseStartupCommand(managed) - this.flushPtyOutput(id) this.requestForceKill(managed) // Why: remote Git deletion must not race the child's native handles; on timeout keep the map entry so onExit/retry still owns it. await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS) @@ -1279,6 +1198,7 @@ export class PtyHandler { if (managed.gracefulKillSent) { return } + this.outputBroadcast.flushForExit(managed.id) managed.gracefulKillSent = true if (process.platform === 'win32') { // Why: ConPTY's bare kill is already force-final; block any later close of the handle. @@ -1333,6 +1253,7 @@ export class PtyHandler { if (managed.forceKillSent || (process.platform === 'win32' && managed.gracefulKillSent)) { return } + this.outputBroadcast.flushForExit(managed.id) managed.forceKillSent = true try { killPtyProcess(managed.pty, 'SIGKILL') @@ -1427,8 +1348,8 @@ export class PtyHandler { } private async serialize(params: Record): Promise { - const ids = params.ids as string[] - const entries: SerializedPtyEntry[] = [] + const ids = parseRelayPtyPersistenceIds(params.ids, MAX_RELAY_PTY_SESSIONS) + const entries: RelayPtyPersistenceEntry[] = [] for (const id of ids) { const managed = this.ptys.get(id) if (!managed) { @@ -1451,12 +1372,11 @@ export class PtyHandler { ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } - return JSON.stringify(entries) + return serializeRelayPtyPersistenceEnvelope(entries, MAX_RELAY_PTY_SESSIONS) } private async revive(params: Record): Promise { - const state = params.state as string - const entries = JSON.parse(state) as SerializedPtyEntry[] + const entries = parseRelayPtyPersistenceEnvelope(params.state, MAX_RELAY_PTY_SESSIONS) for (const entry of entries) { if (this.ptys.has(entry.id) || this.pendingReviveIds.has(entry.id)) { @@ -1482,7 +1402,7 @@ export class PtyHandler { } } - private async reviveEntry(entry: SerializedPtyEntry): Promise { + private async reviveEntry(entry: RelayPtyPersistenceEntry): Promise { const ptyMod = await this.loadPty() if (!ptyMod) { return @@ -1509,7 +1429,7 @@ export class PtyHandler { revivedEnv.TERM = explicitTerm } // Why: serialized state may come from an older/untrusted client; reapply fresh-spawn bounds. - const envToDelete = sanitizeEnvToDelete(entry.envToDelete) + const envToDelete = sanitizeRelayPtyEnvToDelete(entry.envToDelete) const shell = resolveDefaultShell() const spawnEnv = this.buildSpawnEnv( revivedEnv, @@ -1596,16 +1516,8 @@ export class PtyHandler { this.cancelGraceTimer() await this.waitForPendingPtyCreations() for (const managed of this.ptys.values()) { - this.releaseRelayIngress(managed) - this.flushPtyOutput(managed.id) + this.outputBroadcast.flushForExit(managed.id) } - if (this.outputFlushTimer !== null) { - clearTimeout(this.outputFlushTimer) - this.outputFlushTimer = null - } - this.pendingOutputByPty.clear() - this.lastInputAtByPty.clear() - this.interactiveOutputCharsByPty.clear() const results = await Promise.allSettled( [...this.ptys.values()].map((managed) => this.disposePtyForRelayShutdown(managed, waitForPhysicalExit) @@ -1617,6 +1529,9 @@ export class PtyHandler { if (rejected) { throw rejected.reason } + this.outputBroadcast.dispose() + this.lastInputAtByPty.clear() + this.interactiveOutputCharsByPty.clear() } private async disposePtyForRelayShutdown( @@ -1628,9 +1543,9 @@ export class PtyHandler { managed.killTimer = undefined } this.clearStartupCommandTimer(managed) - this.releaseRelayIngress(managed) // Why: retain the native owner until SIGKILL is accepted (one bounded retry) or onExit proves it gone. await this.requestForceKillForRelayShutdown(managed) + this.releaseRelayIngress(managed) if (waitForPhysicalExit && this.ptys.get(managed.id) === managed && !managed.disposed) { try { await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS) diff --git a/src/relay/pty-output-broadcast.test.ts b/src/relay/pty-output-broadcast.test.ts new file mode 100644 index 00000000000..79f3947d37d --- /dev/null +++ b/src/relay/pty-output-broadcast.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RelayDispatcher, RelayNotificationWriteResult } from './dispatcher' +import { PTY_OUTPUT_HIGH_WATER_CHARS, PtyOutputBroadcast } from './pty-output-broadcast' + +type Delivery = { + clientId: number + data: string + deliveryToken: string +} + +function createDispatcher(clientIds: number[]) { + const connected = new Set(clientIds) + const detachListeners = new Set<(clientId: number) => void>() + const deliveries: Delivery[] = [] + const evicted: number[] = [] + const writers = new Map< + number, + (delivery: Delivery) => RelayNotificationWriteResult | undefined + >() + const dispatcher = { + connectedClientIds: () => Array.from(connected), + onClientDetached: (listener: (clientId: number) => void) => { + detachListeners.add(listener) + return () => detachListeners.delete(listener) + }, + notifyClientWithBackpressure: ( + clientId: number, + _method: string, + params: Record = {} + ): RelayNotificationWriteResult => { + const delivery = { + clientId, + data: String(params.data ?? ''), + deliveryToken: String(params.deliveryToken ?? '') + } + deliveries.push(delivery) + return ( + writers.get(clientId)?.(delivery) ?? { + delivered: true, + saturated: false, + drained: Promise.resolve() + } + ) + }, + evictClient: (clientId: number) => { + if (!connected.delete(clientId)) { + return + } + evicted.push(clientId) + for (const listener of detachListeners) { + listener(clientId) + } + } + } as unknown as RelayDispatcher + return { dispatcher, deliveries, evicted, writers } +} + +function deliveryData(deliveries: Delivery[], clientId: number): string { + return deliveries + .filter((delivery) => delivery.clientId === clientId) + .map((delivery) => delivery.data) + .join('') +} + +describe('PtyOutputBroadcast', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('broadcasts identical ordered output to every healthy client', async () => { + const { dispatcher, deliveries } = createDispatcher([1, 2]) + const output = new PtyOutputBroadcast(dispatcher) + output.register('pty-1', { pause: vi.fn(), resume: vi.fn() }) + + output.enqueue('pty-1', { data: 'hello ' }) + output.enqueue('pty-1', { data: 'world' }) + await vi.advanceTimersByTimeAsync(20) + + expect(deliveryData(deliveries, 1)).toBe('hello world') + expect(deliveryData(deliveries, 2)).toBe('hello world') + }) + + it('lets a transiently saturated client drain its exact backlog without stalling peers', async () => { + const { dispatcher, deliveries, writers } = createDispatcher([1, 2]) + let signalDrain!: () => void + let firstWrite = true + writers.set(1, () => { + if (!firstWrite) { + return undefined + } + firstWrite = false + return { + delivered: true, + saturated: true, + drained: new Promise((resolve) => { + signalDrain = resolve + }) + } + }) + const output = new PtyOutputBroadcast(dispatcher) + output.register('pty-1', { pause: vi.fn(), resume: vi.fn() }) + + output.enqueue('pty-1', { data: 'first' }) + await vi.advanceTimersByTimeAsync(8) + output.enqueue('pty-1', { data: ' second' }) + await vi.advanceTimersByTimeAsync(8) + + expect(deliveryData(deliveries, 1)).toBe('first') + expect(deliveryData(deliveries, 2)).toBe('first second') + + signalDrain() + await Promise.resolve() + + expect(deliveryData(deliveries, 1)).toBe('first second') + expect(deliveryData(deliveries, 2)).toBe('first second') + }) + + it('evicts only a permanently slow observer at the shared backlog bound', async () => { + const { dispatcher, deliveries, evicted, writers } = createDispatcher([1, 2]) + writers.set(1, () => ({ + delivered: true, + saturated: true, + drained: new Promise(() => {}) + })) + const pause = vi.fn() + const output = new PtyOutputBroadcast(dispatcher) + output.register('pty-1', { pause, resume: vi.fn() }) + const chunk = 'x'.repeat(16 * 1024) + + for (let index = 0; index <= PTY_OUTPUT_HIGH_WATER_CHARS / chunk.length; index++) { + output.enqueue('pty-1', { data: chunk }) + await vi.advanceTimersByTimeAsync(8) + const healthyDelivery = deliveries.findLast((delivery) => delivery.clientId === 2) + output.acknowledge( + { + id: 'pty-1', + charCount: chunk.length, + deliveryToken: healthyDelivery?.deliveryToken + }, + { clientId: 2, isStale: () => false } + ) + } + + expect(evicted).toEqual([1]) + expect(deliveryData(deliveries, 2)).toHaveLength(PTY_OUTPUT_HIGH_WATER_CHARS + chunk.length) + expect(pause).not.toHaveBeenCalled() + + output.enqueue('pty-1', { data: 'tail' }) + await vi.advanceTimersByTimeAsync(20) + expect(deliveryData(deliveries, 2).endsWith('tail')).toBe(true) + }) + + it('pauses all-blocked producers and rejects cross-client or stale credit', async () => { + const { dispatcher, deliveries } = createDispatcher([7]) + const pause = vi.fn() + const resume = vi.fn() + const output = new PtyOutputBroadcast(dispatcher) + output.register('pty-1', { pause, resume }) + + output.enqueue('pty-1', { data: 'x'.repeat(PTY_OUTPUT_HIGH_WATER_CHARS) }) + const deliveryToken = deliveries[0]?.deliveryToken + + expect(pause).toHaveBeenCalledOnce() + output.acknowledge( + { id: 'pty-1', charCount: PTY_OUTPUT_HIGH_WATER_CHARS, deliveryToken }, + { clientId: 8, isStale: () => false } + ) + output.acknowledge( + { id: 'pty-1', charCount: PTY_OUTPUT_HIGH_WATER_CHARS, deliveryToken: 'stale' }, + { clientId: 7, isStale: () => false } + ) + expect(resume).not.toHaveBeenCalled() + + output.acknowledge( + { id: 'pty-1', charCount: PTY_OUTPUT_HIGH_WATER_CHARS, deliveryToken }, + { clientId: 7, isStale: () => false } + ) + expect(resume).toHaveBeenCalledOnce() + }) + + it('rotates only the reattaching client token and preserves other observers', async () => { + const { dispatcher, deliveries } = createDispatcher([1, 2]) + const output = new PtyOutputBroadcast(dispatcher) + output.register('pty-1', { pause: vi.fn(), resume: vi.fn() }) + + output.enqueue('pty-1', { data: 'before' }) + await vi.advanceTimersByTimeAsync(20) + const oldOne = deliveries.find((delivery) => delivery.clientId === 1)?.deliveryToken + const oldTwo = deliveries.find((delivery) => delivery.clientId === 2)?.deliveryToken + + output.resetClient('pty-1', 1) + output.enqueue('pty-1', { data: 'after' }) + await vi.advanceTimersByTimeAsync(20) + const latestOne = deliveries.findLast((delivery) => delivery.clientId === 1)?.deliveryToken + const latestTwo = deliveries.findLast((delivery) => delivery.clientId === 2)?.deliveryToken + + expect(latestOne).not.toBe(oldOne) + expect(latestTwo).toBe(oldTwo) + expect(deliveryData(deliveries, 2)).toBe('beforeafter') + }) + + it('cancels drain waiters and releases a paused producer on cleanup', async () => { + const { dispatcher, writers } = createDispatcher([1]) + const cancelDrain = vi.fn() + writers.set(1, () => ({ + delivered: true, + saturated: true, + drained: new Promise(() => {}), + cancelDrain + })) + const pause = vi.fn() + const resume = vi.fn() + const output = new PtyOutputBroadcast(dispatcher) + output.register('pty-1', { pause, resume }) + + output.enqueue('pty-1', { data: 'blocked' }) + await vi.advanceTimersByTimeAsync(8) + expect(pause).toHaveBeenCalledOnce() + + output.unregister('pty-1') + + expect(resume).toHaveBeenCalledOnce() + expect(cancelDrain).toHaveBeenCalledOnce() + }) + + it('queues the bounded final tail before exit even when normal credit is exhausted', () => { + const { dispatcher, deliveries } = createDispatcher([1, 2]) + const output = new PtyOutputBroadcast(dispatcher) + output.register('pty-1', { pause: vi.fn(), resume: vi.fn() }) + const data = `${'x'.repeat(PTY_OUTPUT_HIGH_WATER_CHARS)}final tail` + + output.enqueue('pty-1', { data }) + expect(deliveryData(deliveries, 1)).toHaveLength(PTY_OUTPUT_HIGH_WATER_CHARS) + expect(deliveryData(deliveries, 2)).toHaveLength(PTY_OUTPUT_HIGH_WATER_CHARS) + + output.flushForExit('pty-1') + + expect(deliveryData(deliveries, 1)).toBe(data) + expect(deliveryData(deliveries, 2)).toBe(data) + }) +}) diff --git a/src/relay/pty-output-broadcast.ts b/src/relay/pty-output-broadcast.ts new file mode 100644 index 00000000000..379539a4ee6 --- /dev/null +++ b/src/relay/pty-output-broadcast.ts @@ -0,0 +1,300 @@ +import type { IPty } from 'node-pty' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { + appendPtyOutput, + cancelPtyOutputDrains, + canSendPtyOutput, + createPtyOutputState, + ensurePtyOutputClient, + evictablePtyOutputClientIds, + hasSendablePtyOutput, + pendingPtyOutputCharCount, + PTY_OUTPUT_HIGH_WATER_CHARS, + relayPtyOutputCharCount, + removePtyOutputClient, + resumePtyOutputProducer, + updatePtyOutputProducer, + type PtyOutputClientFlow, + type PtyOutputState, + type RelayPtyOutput +} from './pty-output-flow-state' + +export { + PTY_OUTPUT_HIGH_WATER_CHARS, + PTY_OUTPUT_LOW_WATER_CHARS, + type RelayPtyOutput +} from './pty-output-flow-state' + +const PTY_OUTPUT_BATCH_INTERVAL_MS = 8 +const PTY_OUTPUT_DRAIN_CONTINUE_MS = 1 +const PTY_OUTPUT_FLUSH_MAX_WRITES = 2 + +export class PtyOutputBroadcast { + private readonly states = new Map() + private flushTimer: ReturnType | null = null + private unsubscribeClientDetach: (() => void) | null + + constructor(private readonly dispatcher: RelayDispatcher) { + this.unsubscribeClientDetach = dispatcher.onClientDetached((clientId) => { + this.detachClient(clientId) + }) + } + + register(id: string, producer: Pick): void { + this.unregister(id) + this.states.set(id, createPtyOutputState(id, producer)) + } + + enqueue(id: string, output: RelayPtyOutput, immediate = false): void { + const state = this.states.get(id) + if (!state || (output.data.length === 0 && relayPtyOutputCharCount(output) === 0)) { + return + } + const clientIds = this.dispatcher.connectedClientIds() + if (clientIds.length === 0) { + return + } + for (const clientId of clientIds) { + ensurePtyOutputClient(state, clientId) + } + appendPtyOutput(state, output, new Set(clientIds)) + if (pendingPtyOutputCharCount(state) >= PTY_OUTPUT_HIGH_WATER_CHARS) { + this.enforceBacklogBound(state) + } else { + updatePtyOutputProducer(state) + } + if (immediate || output.transformed === true) { + this.flushState(state, PTY_OUTPUT_FLUSH_MAX_WRITES) + this.scheduleContinuationIfNeeded() + return + } + this.scheduleFlush(PTY_OUTPUT_BATCH_INTERVAL_MS) + } + + acknowledge(params: Record, context: RequestContext): void { + const id = params.id + const charCount = params.charCount + const deliveryToken = params.deliveryToken + if ( + typeof id !== 'string' || + typeof charCount !== 'number' || + !Number.isFinite(charCount) || + charCount <= 0 || + typeof deliveryToken !== 'string' + ) { + return + } + const state = this.states.get(id) + const flow = state?.clients.get(context.clientId) + if (!state || !flow || flow.deliveryToken !== deliveryToken) { + return + } + flow.unackedChars = Math.max(0, flow.unackedChars - Math.floor(charCount)) + this.flushState(state, PTY_OUTPUT_FLUSH_MAX_WRITES) + this.scheduleContinuationIfNeeded() + } + + resetClient(id: string, clientId: number): void { + const state = this.states.get(id) + if (!state) { + return + } + removePtyOutputClient(state, clientId) + if (this.dispatcher.connectedClientIds().includes(clientId)) { + ensurePtyOutputClient(state, clientId) + } + updatePtyOutputProducer(state) + this.clearFlushTimerIfIdle() + } + + flushForExit(id: string): void { + const state = this.states.get(id) + if (!state) { + return + } + // Why bypass normal credit: the bounded final tail must be queued before pty.exit tears down the client-side terminal. + this.flushState(state, Number.POSITIVE_INFINITY, true) + } + + unregister(id: string): void { + const state = this.states.get(id) + if (!state) { + return + } + resumePtyOutputProducer(state) + for (const flow of state.clients.values()) { + cancelPtyOutputDrains(flow) + } + state.clients.clear() + state.pending.length = 0 + this.states.delete(id) + this.clearFlushTimerIfIdle() + } + + dispose(): void { + this.unsubscribeClientDetach?.() + this.unsubscribeClientDetach = null + for (const id of Array.from(this.states.keys())) { + this.unregister(id) + } + if (this.flushTimer) { + clearTimeout(this.flushTimer) + this.flushTimer = null + } + } + + private flushPending(): void { + this.flushTimer = null + let writesRemaining = PTY_OUTPUT_FLUSH_MAX_WRITES + for (const state of this.states.values()) { + if (writesRemaining <= 0) { + break + } + writesRemaining -= this.flushState(state, 1) + } + if (this.hasSendablePendingOutput()) { + this.scheduleFlush(PTY_OUTPUT_DRAIN_CONTINUE_MS) + } + } + + private flushState(state: PtyOutputState, maxWrites: number, force = false): number { + let writes = 0 + const blockedClientIds = new Set() + for (let entryIndex = 0; entryIndex < state.pending.length; ) { + if (writes >= maxWrites) { + break + } + const pending = state.pending[entryIndex] + for (const clientId of Array.from(pending.pendingClientIds)) { + if (writes >= maxWrites) { + break + } + if (blockedClientIds.has(clientId)) { + continue + } + const flow = state.clients.get(clientId) + if (!flow) { + pending.pendingClientIds.delete(clientId) + continue + } + if (!force && !canSendPtyOutput(flow)) { + blockedClientIds.add(clientId) + continue + } + pending.pendingClientIds.delete(clientId) + this.sendToClient(state, flow, pending) + writes++ + if (state.pending[entryIndex] !== pending) { + break + } + if (!force && !canSendPtyOutput(flow)) { + blockedClientIds.add(clientId) + } + } + if (state.pending[entryIndex] !== pending) { + continue + } + if (pending.pendingClientIds.size === 0) { + state.pending.splice(entryIndex, 1) + } else { + entryIndex++ + } + } + if (pendingPtyOutputCharCount(state) >= PTY_OUTPUT_HIGH_WATER_CHARS) { + this.evictLaggingClientsWhenHealthy(state) + } + updatePtyOutputProducer(state) + this.clearFlushTimerIfIdle() + return writes + } + + private sendToClient( + state: PtyOutputState, + flow: PtyOutputClientFlow, + output: RelayPtyOutput + ): void { + const result = this.dispatcher.notifyClientWithBackpressure(flow.clientId, 'pty.data', { + id: state.id, + data: output.data, + ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), + ...(output.transformed ? { transformed: true } : {}), + ...(output.seq === undefined ? {} : { seq: output.seq }), + deliveryToken: flow.deliveryToken + }) + if (!result.delivered || state.clients.get(flow.clientId) !== flow) { + removePtyOutputClient(state, flow.clientId) + return + } + flow.unackedChars += relayPtyOutputCharCount(output) + if (!result.saturated) { + return + } + flow.sinkBackpressured = true + if (result.cancelDrain) { + flow.drainCancellations.add(result.cancelDrain) + } + void result.drained.then(() => { + if (result.cancelDrain) { + flow.drainCancellations.delete(result.cancelDrain) + } + if (state.clients.get(flow.clientId) !== flow) { + return + } + flow.sinkBackpressured = false + this.flushState(state, PTY_OUTPUT_FLUSH_MAX_WRITES) + this.scheduleContinuationIfNeeded() + }) + } + + private detachClient(clientId: number): void { + for (const state of this.states.values()) { + removePtyOutputClient(state, clientId) + updatePtyOutputProducer(state) + } + this.clearFlushTimerIfIdle() + } + + private enforceBacklogBound(state: PtyOutputState): void { + this.flushState(state, Number.POSITIVE_INFINITY) + if (pendingPtyOutputCharCount(state) < PTY_OUTPUT_HIGH_WATER_CHARS) { + return + } + this.evictLaggingClientsWhenHealthy(state) + updatePtyOutputProducer(state) + } + + private evictLaggingClientsWhenHealthy(state: PtyOutputState): void { + for (const clientId of evictablePtyOutputClientIds(state)) { + this.dispatcher.evictClient(clientId) + this.detachClient(clientId) + } + } + + private hasSendablePendingOutput(): boolean { + return hasSendablePtyOutput(this.states.values()) + } + + private scheduleFlush(delayMs: number): void { + if (this.flushTimer) { + return + } + this.flushTimer = setTimeout(() => this.flushPending(), delayMs) + } + + private scheduleContinuationIfNeeded(): void { + if (this.hasSendablePendingOutput()) { + this.scheduleFlush(PTY_OUTPUT_DRAIN_CONTINUE_MS) + } + } + + private clearFlushTimerIfIdle(): void { + if ( + this.flushTimer === null || + Array.from(this.states.values()).some((s) => s.pending.length) + ) { + return + } + clearTimeout(this.flushTimer) + this.flushTimer = null + } +} diff --git a/src/relay/pty-output-flow-state.ts b/src/relay/pty-output-flow-state.ts new file mode 100644 index 00000000000..f0761985c03 --- /dev/null +++ b/src/relay/pty-output-flow-state.ts @@ -0,0 +1,240 @@ +import { randomUUID } from 'node:crypto' +import type { IPty } from 'node-pty' + +export const PTY_OUTPUT_HIGH_WATER_CHARS = 256 * 1024 +export const PTY_OUTPUT_LOW_WATER_CHARS = 32 * 1024 + +const PTY_OUTPUT_FLUSH_CHUNK_CHARS = 16 * 1024 + +export type RelayPtyOutput = { + data: string + rawLength?: number + transformed?: boolean + seq?: number +} + +export type PtyOutputClientFlow = { + clientId: number + deliveryToken: string + unackedChars: number + sinkBackpressured: boolean + drainCancellations: Set<() => void> +} + +export type PendingPtyOutput = RelayPtyOutput & { + pendingClientIds: Set +} + +export type PtyOutputState = { + id: string + producer: Pick + clients: Map + pending: PendingPtyOutput[] + producerPaused: boolean +} + +export function createPtyOutputState( + id: string, + producer: Pick +): PtyOutputState { + return { id, producer, clients: new Map(), pending: [], producerPaused: false } +} + +export function relayPtyOutputCharCount(output: RelayPtyOutput): number { + return typeof output.rawLength === 'number' && Number.isFinite(output.rawLength) + ? Math.max(0, Math.floor(output.rawLength)) + : output.data.length +} + +function setsEqual(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const value of left) { + if (!right.has(value)) { + return false + } + } + return true +} + +function appendChunk( + state: PtyOutputState, + output: RelayPtyOutput, + pendingClientIds: Set +): void { + const tail = state.pending.at(-1) + const tailChars = tail ? relayPtyOutputCharCount(tail) : 0 + const addedChars = relayPtyOutputCharCount(output) + if ( + tail && + tail.transformed === output.transformed && + setsEqual(tail.pendingClientIds, pendingClientIds) && + tailChars + addedChars <= PTY_OUTPUT_FLUSH_CHUNK_CHARS + ) { + const previousLength = tail.data.length + tail.data += output.data + if (tail.rawLength !== undefined || output.rawLength !== undefined) { + tail.rawLength = (tail.rawLength ?? previousLength) + addedChars + } + if (output.seq !== undefined) { + tail.seq = output.seq + } + return + } + state.pending.push({ ...output, pendingClientIds }) +} + +export function appendPtyOutput( + state: PtyOutputState, + output: RelayPtyOutput, + pendingClientIds: Set +): void { + if (output.transformed || output.data.length <= PTY_OUTPUT_FLUSH_CHUNK_CHARS) { + appendChunk(state, output, pendingClientIds) + return + } + for (let offset = 0; offset < output.data.length; offset += PTY_OUTPUT_FLUSH_CHUNK_CHARS) { + const data = output.data.slice(offset, offset + PTY_OUTPUT_FLUSH_CHUNK_CHARS) + const remainingChars = output.data.length - offset - data.length + appendChunk( + state, + { + data, + ...(output.rawLength === undefined ? {} : { rawLength: data.length }), + ...(output.seq === undefined ? {} : { seq: output.seq - remainingChars }) + }, + new Set(pendingClientIds) + ) + } +} + +export function ensurePtyOutputClient( + state: PtyOutputState, + clientId: number +): PtyOutputClientFlow { + const existing = state.clients.get(clientId) + if (existing) { + return existing + } + const flow: PtyOutputClientFlow = { + clientId, + deliveryToken: randomUUID(), + unackedChars: 0, + sinkBackpressured: false, + drainCancellations: new Set() + } + state.clients.set(clientId, flow) + return flow +} + +export function cancelPtyOutputDrains(flow: PtyOutputClientFlow): void { + for (const cancel of flow.drainCancellations) { + cancel() + } + flow.drainCancellations.clear() +} + +export function removePtyOutputClient(state: PtyOutputState, clientId: number): void { + const flow = state.clients.get(clientId) + if (flow) { + cancelPtyOutputDrains(flow) + state.clients.delete(clientId) + } + for (let index = state.pending.length - 1; index >= 0; index--) { + const pending = state.pending[index] + pending.pendingClientIds.delete(clientId) + if (pending.pendingClientIds.size === 0) { + state.pending.splice(index, 1) + } + } +} + +export function canSendPtyOutput(flow: PtyOutputClientFlow): boolean { + return !flow.sinkBackpressured && flow.unackedChars < PTY_OUTPUT_HIGH_WATER_CHARS +} + +export function pendingPtyOutputCharCount(state: PtyOutputState, clientId?: number): number { + return state.pending.reduce( + (total, pending) => + total + + (clientId === undefined || pending.pendingClientIds.has(clientId) + ? relayPtyOutputCharCount(pending) + : 0), + 0 + ) +} + +export function resumePtyOutputProducer(state: PtyOutputState): void { + if (!state.producerPaused) { + return + } + try { + state.producer.resume() + } catch { + /* PTY already destroyed */ + } + state.producerPaused = false +} + +export function updatePtyOutputProducer(state: PtyOutputState): void { + const flows = Array.from(state.clients.values()) + if (flows.length === 0) { + resumePtyOutputProducer(state) + return + } + if (flows.every((flow) => !canSendPtyOutput(flow))) { + if (!state.producerPaused) { + try { + state.producer.pause() + state.producerPaused = true + } catch { + /* PTY already destroyed */ + } + } + return + } + if ( + state.producerPaused && + flows.some( + (flow) => + !flow.sinkBackpressured && + flow.unackedChars + pendingPtyOutputCharCount(state, flow.clientId) <= + PTY_OUTPUT_LOW_WATER_CHARS + ) + ) { + resumePtyOutputProducer(state) + } +} + +export function hasSendablePtyOutput(states: Iterable): boolean { + for (const state of states) { + for (const pending of state.pending) { + for (const clientId of pending.pendingClientIds) { + const flow = state.clients.get(clientId) + if (flow && canSendPtyOutput(flow)) { + return true + } + } + } + } + return false +} + +export function evictablePtyOutputClientIds(state: PtyOutputState): number[] { + const laggingClientIds = new Set() + for (const pending of state.pending) { + for (const clientId of pending.pendingClientIds) { + laggingClientIds.add(clientId) + } + } + if ( + laggingClientIds.size === 0 || + !Array.from(state.clients.values()).some( + (flow) => !laggingClientIds.has(flow.clientId) && canSendPtyOutput(flow) + ) + ) { + return [] + } + return Array.from(laggingClientIds) +} diff --git a/src/relay/pty-persistence-envelope.test.ts b/src/relay/pty-persistence-envelope.test.ts new file mode 100644 index 00000000000..3e01c635641 --- /dev/null +++ b/src/relay/pty-persistence-envelope.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES, + MAX_RELAY_PTY_PERSISTENCE_RETAINED_BYTES, + MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES, + parseRelayPtyPersistenceEnvelope, + serializeRelayPtyPersistenceEnvelope, + type RelayPtyPersistenceEntry +} from './pty-persistence-envelope' +import { MAX_TERMINAL_COLS } from '../shared/terminal-size-limits' + +function entry(index: number, overrides: Partial = {}) { + return { + id: `pty-${index}`, + pid: 100 + index, + cols: 80, + rows: 24, + cwd: '/repo', + envToDelete: [], + gitCredentialPromptGuarded: false, + ...overrides + } +} + +describe('relay PTY persistence envelope', () => { + it('round-trips normal state without changing retained fields', () => { + const entries = [ + entry(1, { + paneKey: 'tab-1:leaf-1', + attachIdentity: { paneKey: 'tab-1:leaf-1', tabId: 'tab-1' }, + worktreeId: 'repo::/repo', + terminalHandle: 'terminal-1', + explicitTerm: 'screen-256color', + envToDelete: ['ORCA_ATTRIBUTION_SHIM_DIR'], + gitCredentialPromptGuarded: true + }) + ] + + const serialized = serializeRelayPtyPersistenceEnvelope(entries, 50) + + expect(parseRelayPtyPersistenceEnvelope(serialized, 50)).toEqual(entries) + }) + + it('accepts the field limit and rejects limit plus one', () => { + expect(() => + serializeRelayPtyPersistenceEnvelope( + [entry(1, { cwd: 'x'.repeat(MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES) })], + 50 + ) + ).not.toThrow() + expect(() => + serializeRelayPtyPersistenceEnvelope( + [entry(1, { cwd: 'x'.repeat(MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES + 1) })], + 50 + ) + ).toThrow(`exceeds ${MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES} bytes`) + }) + + it('rejects aggregate retained fields before serialization allocates the output', () => { + const field = 'x'.repeat(63 * 1024) + const entries = Array.from({ length: 49 }, (_, index) => + entry(index, { cwd: field, paneKey: field }) + ) + + expect(() => serializeRelayPtyPersistenceEnvelope(entries, 50)).toThrow( + `exceeds ${MAX_RELAY_PTY_PERSISTENCE_RETAINED_BYTES} retained bytes` + ) + }) + + it('rejects oversized and deeply nested input before JSON.parse', () => { + expect(() => + parseRelayPtyPersistenceEnvelope(' '.repeat(MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES + 1), 50) + ).toThrow(`exceeds ${MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES} bytes`) + + const deeplyNested = JSON.stringify([ + { + ...entry(1), + ignored: [[[[[[[[]]]]]]]] + } + ]) + expect(() => parseRelayPtyPersistenceEnvelope(deeplyNested, 50)).toThrow( + 'JSON nesting exceeds 8 levels' + ) + }) + + it('rejects entry-count overflow transactionally', () => { + const serialized = JSON.stringify([entry(1), entry(2)]) + expect(() => parseRelayPtyPersistenceEnvelope(serialized, 1)).toThrow( + 'PTY persistence state exceeds 1 entries' + ) + }) + + it('rejects oversized terminal dimensions on writes and reads', () => { + const oversized = entry(1, { cols: MAX_TERMINAL_COLS + 1 }) + expect(() => serializeRelayPtyPersistenceEnvelope([oversized], 50)).toThrow( + `1 through ${MAX_TERMINAL_COLS}` + ) + expect(() => parseRelayPtyPersistenceEnvelope(JSON.stringify([oversized]), 50)).toThrow( + `1 through ${MAX_TERMINAL_COLS}` + ) + }) +}) diff --git a/src/relay/pty-persistence-envelope.ts b/src/relay/pty-persistence-envelope.ts new file mode 100644 index 00000000000..033333bbaa6 --- /dev/null +++ b/src/relay/pty-persistence-envelope.ts @@ -0,0 +1,243 @@ +import { assertJsonTextStructureWithinLimits } from '../shared/memory-safety/json-text-structure-limit' +import { stringifyJsonWithinByteLimit } from '../shared/memory-safety/node-bounded-json-stringify' +import { terminalSizeAdmissionError } from '../shared/terminal-size-limits' + +export const MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES = 8 * 1024 * 1024 +export const MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES = 64 * 1024 +export const MAX_RELAY_PTY_PERSISTENCE_ENTRY_BYTES = 128 * 1024 +export const MAX_RELAY_PTY_PERSISTENCE_RETAINED_BYTES = 6 * 1024 * 1024 +export const MAX_RELAY_PTY_ENV_DELETE_KEYS = 1_024 + +const PTY_PERSISTENCE_JSON_STRUCTURE_LIMITS = { + structuralTokens: 131_072, + nestingDepth: 8 +} as const + +export type RelayPtyIdentity = { + paneKey?: string + tabId?: string +} + +export type RelayPtyPersistenceEntry = { + id: string + pid: number + cols: number + rows: number + cwd: string + paneKey?: string + tabId?: string + attachIdentity?: RelayPtyIdentity + worktreeId?: string + terminalHandle?: string + explicitTerm?: string + envToDelete?: string[] + gitCredentialPromptGuarded?: boolean +} + +export type RelayPtyRetainedFields = Pick< + RelayPtyPersistenceEntry, + | 'id' + | 'cwd' + | 'paneKey' + | 'tabId' + | 'attachIdentity' + | 'worktreeId' + | 'terminalHandle' + | 'explicitTerm' + | 'envToDelete' +> + +export function sanitizeRelayPtyEnvToDelete(value: unknown): string[] { + return Array.isArray(value) + ? value + .filter((key): key is string => typeof key === 'string' && key.length > 0) + .slice(0, MAX_RELAY_PTY_ENV_DELETE_KEYS) + : [] +} + +export function assertRelayPtyRetainedFieldsWithinLimits(fields: RelayPtyRetainedFields): number { + let retainedBytes = 0 + const add = (field: string, value: string | undefined): void => { + if (value === undefined) { + return + } + const bytes = Buffer.byteLength(value, 'utf8') + if (bytes > MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES) { + throw new Error( + `PTY persistence field "${field}" exceeds ${MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES} bytes` + ) + } + retainedBytes += bytes + } + + add('id', fields.id) + add('cwd', fields.cwd) + add('paneKey', fields.paneKey) + add('tabId', fields.tabId) + add('attachIdentity.paneKey', fields.attachIdentity?.paneKey) + add('attachIdentity.tabId', fields.attachIdentity?.tabId) + add('worktreeId', fields.worktreeId) + add('terminalHandle', fields.terminalHandle) + add('explicitTerm', fields.explicitTerm) + for (const key of fields.envToDelete ?? []) { + add('envToDelete', key) + } + if (retainedBytes > MAX_RELAY_PTY_PERSISTENCE_ENTRY_BYTES) { + throw new Error( + `PTY persistence entry exceeds ${MAX_RELAY_PTY_PERSISTENCE_ENTRY_BYTES} retained bytes` + ) + } + return retainedBytes +} + +export function serializeRelayPtyPersistenceEnvelope( + entries: readonly RelayPtyPersistenceEntry[], + maxEntries: number +): string { + assertEnvelopeEntryCount(entries, maxEntries) + assertEnvelopeRetainedBytes(entries) + return stringifyJsonWithinByteLimit(entries, MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES).serialized +} + +export function parseRelayPtyPersistenceEnvelope( + state: unknown, + maxEntries: number +): RelayPtyPersistenceEntry[] { + if (typeof state !== 'string') { + throw new Error('PTY persistence state must be JSON text') + } + if (Buffer.byteLength(state, 'utf8') > MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES) { + throw new Error(`PTY persistence state exceeds ${MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES} bytes`) + } + assertJsonTextStructureWithinLimits(state, PTY_PERSISTENCE_JSON_STRUCTURE_LIMITS) + return normalizeEnvelope(JSON.parse(state) as unknown, maxEntries) +} + +export function parseRelayPtyPersistenceIds(value: unknown, maxEntries: number): string[] { + if (!Array.isArray(value) || value.length > maxEntries) { + throw new Error(`PTY persistence request exceeds ${maxEntries} entries`) + } + return value.map((id) => requiredString(id, 'id')) +} + +function normalizeEnvelope(value: unknown, maxEntries: number): RelayPtyPersistenceEntry[] { + if (!Array.isArray(value)) { + throw new Error(`PTY persistence state exceeds ${maxEntries} entries`) + } + assertEnvelopeEntryCount(value, maxEntries) + + const entries = value.map((entry, index) => normalizeEntry(entry, index)) + assertEnvelopeRetainedBytes(entries) + return entries +} + +function assertEnvelopeEntryCount(value: readonly unknown[], maxEntries: number): void { + if (!Number.isSafeInteger(maxEntries) || maxEntries < 0) { + throw new RangeError('PTY persistence entry limit must be a non-negative safe integer') + } + if (value.length > maxEntries) { + throw new Error(`PTY persistence state exceeds ${maxEntries} entries`) + } +} + +function assertEnvelopeRetainedBytes(entries: readonly RelayPtyRetainedFields[]): void { + let retainedBytes = 0 + for (const entry of entries) { + const dimensions = entry as Partial + const sizeError = terminalSizeAdmissionError( + dimensions.cols, + dimensions.rows, + 'PTY persistence entry', + { allowMissing: true } + ) + if (sizeError) { + throw new Error(sizeError) + } + retainedBytes += assertRelayPtyRetainedFieldsWithinLimits(entry) + if (retainedBytes > MAX_RELAY_PTY_PERSISTENCE_RETAINED_BYTES) { + throw new Error( + `PTY persistence state exceeds ${MAX_RELAY_PTY_PERSISTENCE_RETAINED_BYTES} retained bytes` + ) + } + } +} + +function normalizeEntry(value: unknown, index: number): RelayPtyPersistenceEntry { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`PTY persistence entry ${index} must be an object`) + } + const entry = value as Record + const attachIdentity = normalizeIdentity(entry.attachIdentity) + const paneKey = optionalString(entry.paneKey, 'paneKey') + const tabId = optionalString(entry.tabId, 'tabId') + const worktreeId = optionalString(entry.worktreeId, 'worktreeId') + const terminalHandle = optionalString(entry.terminalHandle, 'terminalHandle') + const explicitTerm = optionalString(entry.explicitTerm, 'explicitTerm') + const envToDelete = sanitizeRelayPtyEnvToDelete(entry.envToDelete) + const cols = positiveSafeIntegerOrDefault(entry.cols, 'cols', 80) + const rows = positiveSafeIntegerOrDefault(entry.rows, 'rows', 24) + const sizeError = terminalSizeAdmissionError(cols, rows, 'PTY persistence entry') + if (sizeError) { + throw new Error(sizeError) + } + + return { + id: requiredString(entry.id, 'id'), + pid: positiveSafeInteger(entry.pid, 'pid'), + cols, + rows, + cwd: requiredString(entry.cwd, 'cwd'), + ...(paneKey === undefined ? {} : { paneKey }), + ...(tabId === undefined ? {} : { tabId }), + ...(attachIdentity === undefined ? {} : { attachIdentity }), + ...(worktreeId === undefined ? {} : { worktreeId }), + ...(terminalHandle === undefined ? {} : { terminalHandle }), + ...(explicitTerm === undefined ? {} : { explicitTerm }), + ...(Array.isArray(entry.envToDelete) ? { envToDelete } : {}), + gitCredentialPromptGuarded: entry.gitCredentialPromptGuarded === true + } +} + +function normalizeIdentity(value: unknown): RelayPtyIdentity | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + const identity = value as Record + const paneKey = optionalString(identity.paneKey, 'attachIdentity.paneKey') + const tabId = optionalString(identity.tabId, 'attachIdentity.tabId') + return paneKey === undefined && tabId === undefined ? undefined : { paneKey, tabId } +} + +function requiredString(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new Error(`PTY persistence field "${field}" must be a string`) + } + assertRelayPtyPersistenceFieldWithinLimit(field, value) + return value +} + +function optionalString(value: unknown, field: string): string | undefined { + if (value === undefined) { + return undefined + } + return requiredString(value, field) +} + +export function assertRelayPtyPersistenceFieldWithinLimit(field: string, value: string): void { + if (Buffer.byteLength(value, 'utf8') > MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES) { + throw new Error( + `PTY persistence field "${field}" exceeds ${MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES} bytes` + ) + } +} + +function positiveSafeInteger(value: unknown, field: string): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new Error(`PTY persistence field "${field}" must be a positive safe integer`) + } + return value as number +} + +function positiveSafeIntegerOrDefault(value: unknown, field: string, fallback: number): number { + return value === undefined ? fallback : positiveSafeInteger(value, field) +} diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index c4c5b5f11ad..fb8fc4dc693 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -137,6 +137,24 @@ describe('getRelayShellLaunchConfig', () => { ) }) + it.skipIf(process.platform === 'win32')( + 'replaces a sparse oversized wrapper without reading its payload', + () => { + const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') + const wrapperPath = join(zshRoot, '.zshenv') + mkdirSync(zshRoot, { recursive: true }) + writeFileSync(wrapperPath, '') + truncateSync(wrapperPath, 1024 * 1024 * 1024) + + getRelayShellLaunchConfig('/bin/zsh', { + HOME: homeDir, + ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay' + }) + + expect(readFileSync(wrapperPath, 'utf8')).toContain('export ORCA_USER_ZDOTDIR=') + } + ) + it.skipIf(process.platform === 'win32')( 'wraps zsh when MiMo home must survive shell startup', () => { diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 5f58b89702c..0af665c199d 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' @@ -7,6 +7,7 @@ import { getZshShellReadyMarkerRegistrationBlock, getZshStartupFileSourceBlock } from '../main/shell-templates' +import { readNodeFileSyncWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' const POSIX_LOGIN_ARGS = ['-l'] @@ -246,7 +247,10 @@ trap '__orca_osc133_preexec' DEBUG mkdirSync(dirname(path), { recursive: true }) let existing: string | null = null try { - existing = readFileSync(path, 'utf8') + existing = readNodeFileSyncWithinLimit( + path, + Buffer.byteLength(content, 'utf8') + ).buffer.toString('utf8') } catch { existing = null } diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts index d4f69f4dd4b..b880c5a64e7 100644 --- a/src/relay/pty-shell-utils.test.ts +++ b/src/relay/pty-shell-utils.test.ts @@ -1,13 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - execFileSyncMock: vi.fn() +const { execFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn() })) vi.mock('child_process', () => ({ - execFile: execFileMock, - execFileSync: execFileSyncMock + execFile: execFileMock })) import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows' @@ -51,9 +49,7 @@ async function withProcessPlatform( } beforeEach(() => { - vi.resetModules() execFileMock.mockReset() - execFileSyncMock.mockReset() resetProcessTableSnapshotForTests() resetWindowsProcessRowsSnapshotForTests() }) @@ -101,124 +97,11 @@ describe('resolveWindowsDefaultShell', () => { SystemRoot: 'C:\\Windows', ComSpec: 'C:\\Windows\\System32\\cmd.exe' }, - (path) => path === 'C:\\Tools\\pwsh.exe', - () => { - throw new Error('DefaultShell should not be read when SHELL wins') - } + (path) => path === 'C:\\Tools\\pwsh.exe' ) ).toBe('C:\\Tools\\pwsh.exe') }) - it('uses an existing OpenSSH DefaultShell path', () => { - const powershell7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' - - expect( - resolveWindowsDefaultShell( - { - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === powershell7, - () => powershell7 - ) - ).toBe(powershell7) - }) - - it('reads and memoizes the OpenSSH DefaultShell registry value', async () => { - execFileSyncMock.mockReturnValue( - [ - 'HKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH', - ' DefaultShell REG_SZ C:\\Program Files\\PowerShell\\7\\pwsh.exe' - ].join('\n') - ) - - const { readOpenSshDefaultShell } = await import('./pty-shell-utils') - - expect(readOpenSshDefaultShell()).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe') - expect(readOpenSshDefaultShell()).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe') - expect(execFileSyncMock).toHaveBeenCalledTimes(1) - expect(execFileSyncMock).toHaveBeenCalledWith( - 'reg.exe', - ['query', 'HKLM\\SOFTWARE\\OpenSSH', '/v', 'DefaultShell'], - { encoding: 'utf8', timeout: 3000, windowsHide: true } - ) - }) - - it('treats malformed OpenSSH DefaultShell output as empty and preserves the fallback chain', async () => { - execFileSyncMock.mockReturnValue( - [ - 'HKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH', - ' DefaultShellCommandOption REG_SZ /c' - ].join('\n') - ) - - const { readOpenSshDefaultShell } = await import('./pty-shell-utils') - const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' - - expect(readOpenSshDefaultShell()).toBe('') - expect( - resolveWindowsDefaultShell( - { - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === powershell || path === 'C:\\Windows\\System32\\cmd.exe', - readOpenSshDefaultShell - ) - ).toBe(powershell) - }) - - it('treats reg.exe failures as empty and preserves the fallback chain', async () => { - execFileSyncMock.mockImplementation(() => { - throw new Error('reg.exe failed') - }) - - const { readOpenSshDefaultShell } = await import('./pty-shell-utils') - const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' - - expect(readOpenSshDefaultShell()).toBe('') - expect( - resolveWindowsDefaultShell( - { - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === powershell || path === 'C:\\Windows\\System32\\cmd.exe', - readOpenSshDefaultShell - ) - ).toBe(powershell) - }) - - it('preserves the fallback chain for an invalid OpenSSH DefaultShell', () => { - const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' - - expect( - resolveWindowsDefaultShell( - { - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === powershell, - () => 'C:\\missing\\pwsh.exe' - ) - ).toBe(powershell) - }) - - it('honors a deliberate OpenSSH PowerShell 5.1 DefaultShell value', () => { - const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' - - expect( - resolveWindowsDefaultShell( - { - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === powershell, - () => powershell - ) - ).toBe(powershell) - }) - it('prefers inbox PowerShell before ComSpec for an interactive Windows PTY', () => { const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' @@ -228,8 +111,7 @@ describe('resolveWindowsDefaultShell', () => { SystemRoot: 'C:\\Windows', ComSpec: 'C:\\Windows\\System32\\cmd.exe' }, - (path) => path === powershell || path === 'C:\\Windows\\System32\\cmd.exe', - () => '' + (path) => path === powershell || path === 'C:\\Windows\\System32\\cmd.exe' ) ).toBe(powershell) }) @@ -241,8 +123,7 @@ describe('resolveWindowsDefaultShell', () => { SystemRoot: 'C:\\Windows', ComSpec: 'C:\\Windows\\System32\\cmd.exe' }, - (path) => path === 'C:\\Windows\\System32\\cmd.exe', - () => '' + (path) => path === 'C:\\Windows\\System32\\cmd.exe' ) ).toBe('C:\\Windows\\System32\\cmd.exe') }) diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index 9860917c588..11e33cb87c7 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -1,8 +1,9 @@ -import { execFile as execFileCb, execFileSync } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' +import { execFile as execFileCb } from 'node:child_process' +import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { win32 as pathWin32 } from 'node:path' import { promisify } from 'node:util' +import { readNodeFileSyncWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, @@ -22,45 +23,17 @@ import { } from '../main/providers/windows-agent-foreground-process' const execFile = promisify(execFileCb) - -const OPENSSH_REGISTRY_KEY = 'HKLM\\SOFTWARE\\OpenSSH' -let openSshDefaultShell: string | undefined - -export function readOpenSshDefaultShell(): string { - if (openSshDefaultShell !== undefined) { - return openSshDefaultShell - } - - try { - const output = execFileSync('reg.exe', ['query', OPENSSH_REGISTRY_KEY, '/v', 'DefaultShell'], { - encoding: 'utf8', - timeout: 3000, - windowsHide: true - }) - const match = output.match(/^\s*DefaultShell\s+REG_\w+\s+(.+?)\s*$/im) - openSshDefaultShell = match?.[1] ?? '' - } catch { - openSshDefaultShell = '' - } - - return openSshDefaultShell -} +const MAX_ETC_SHELLS_BYTES = 64 * 1024 export function resolveWindowsDefaultShell( env: NodeJS.ProcessEnv = process.env, - existsPath: (path: string) => boolean = existsSync, - readDefaultShell: () => string = readOpenSshDefaultShell + existsPath: (path: string) => boolean = existsSync ): string { const envShell = env.SHELL if (envShell && existsPath(envShell)) { return envShell } - const configuredShell = readDefaultShell() - if (configuredShell && existsPath(configuredShell)) { - return configuredShell - } - const systemRoot = env.SystemRoot || env.WINDIR || env.windir || 'C:\\Windows' const windowsPowerShell = pathWin32.join( systemRoot, @@ -340,7 +313,10 @@ export function listShellProfiles(): { name: string; path: string }[] { const seen = new Set() try { - const content = readFileSync('/etc/shells', 'utf-8') + const content = readNodeFileSyncWithinLimit( + '/etc/shells', + MAX_ETC_SHELLS_BYTES + ).buffer.toString('utf8') for (const line of content.split('\n')) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) { diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index 9a21fb9eace..800cb43fd86 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -11,6 +11,7 @@ import type { import type { RelayDispatcher, RequestContext } from './dispatcher' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import { createRelayWatcherProcessPool } from './relay-watcher-process-pool' +import { MAX_RELAY_WATCH_ROOT_KEY_BYTES } from './relay-watcher-root-capacity' type InstalledWatch = { rootPath: string @@ -86,6 +87,21 @@ describe('RelayFilesystemWatchRegistry', () => { registry = new RelayFilesystemWatchRegistry(dispatcher as unknown as RelayDispatcher, pool) }) + it('rejects an oversized raw root before retaining a pending setup', async () => { + await expect( + registry.watch('/'.repeat(MAX_RELAY_WATCH_ROOT_KEY_BYTES + 1), context(1)) + ).rejects.toThrow('File watcher root path is too long') + + expect(pool.installed).toHaveLength(0) + expect( + ( + registry as unknown as { + pendingSetups: Map + } + ).pendingSetups.size + ).toBe(0) + }) + it('emits overflow around child replacement and resumes ordered event delivery', async () => { await registry.watch('/repo', context(1)) const first = pool.installed[0] diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts index 4acb786fa23..94977c01ab2 100644 --- a/src/relay/relay-filesystem-watch-registry.ts +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -16,7 +16,10 @@ import { type RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' -import { assertRelayWatcherRootCapacity } from './relay-watcher-root-capacity' +import { + assertRelayWatcherRootCapacity, + assertRelayWatcherRootKeyCapacity +} from './relay-watcher-root-capacity' import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import { trackRelayWatcherSetup, @@ -85,6 +88,7 @@ export class RelayFilesystemWatchRegistry { context?: RequestContext, watchId?: number ): Promise { + assertRelayWatcherRootKeyCapacity(rootKey, rootPath) const staleTeardown = releaseStaleRelayWatches(this.watches.values(), (state) => this.closeWatch(state) ) diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts index e021c392561..b1a7da5b5dc 100644 --- a/src/relay/relay-handshake.ts +++ b/src/relay/relay-handshake.ts @@ -1,8 +1,9 @@ // Wire-level handshake helpers for the Orca relay. import { dirname, join } from 'node:path' -import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { existsSync, realpathSync } from 'node:fs' import type { Socket } from 'node:net' +import { readRelayVersionMarkerSync } from '../shared/relay-version-marker' import { RELAY_VERSION, MessageType, @@ -34,7 +35,7 @@ export function readLaunchVersion(): string { } const versionFile = join(dir, '.version') if (existsSync(versionFile)) { - const v = readFileSync(versionFile, 'utf-8').trim() + const v = readRelayVersionMarkerSync(versionFile) if (v) { return v } diff --git a/src/relay/relay-watcher-removal-fence.ts b/src/relay/relay-watcher-removal-fence.ts index 9b0b62ab780..fdda69abc67 100644 --- a/src/relay/relay-watcher-removal-fence.ts +++ b/src/relay/relay-watcher-removal-fence.ts @@ -6,6 +6,9 @@ import type { RelayDispatcher } from './dispatcher' import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' import { isPathInsideOrEqual } from '../shared/cross-platform-path' import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' +import { forEachWithConcurrency, mapSettledWithConcurrency } from '../shared/map-with-concurrency' + +const RELAY_WATCH_CLOSE_CONCURRENCY = 8 export class RelayWatcherRemovalFence { private readonly roots = new Set() @@ -102,7 +105,9 @@ export class RelayWatcherRemovalFence { const pending = [...this.pendingSetups.entries()].filter(([setupRoot]) => isPathInsideOrEqual(rootKey, setupRoot) ) - await Promise.all(pending.map(([, setup]) => setup.promise.catch(() => undefined))) + await forEachWithConcurrency(pending, RELAY_WATCH_CLOSE_CONCURRENCY, async ([, setup]) => { + await setup.promise.catch(() => undefined) + }) const states = [...this.watches.entries()] .filter(([watchRoot]) => isPathInsideOrEqual(rootKey, watchRoot)) .map(([, state]) => state) @@ -111,7 +116,15 @@ export class RelayWatcherRemovalFence { state.clients.clear() state.clientWatchIds.clear() } - await Promise.all(states.map((state) => this.closeWatch(state))) + const closes = await mapSettledWithConcurrency(states, RELAY_WATCH_CLOSE_CONCURRENCY, (state) => + this.closeWatch(state) + ) + const failedClose = closes.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + if (failedClose) { + throw failedClose.reason + } const trackedRoots = this.teardownTracker .rootPaths() .filter((trackedRoot) => isPathInsideOrEqual(rootKey, trackedRoot)) diff --git a/src/relay/relay-watcher-root-capacity.test.ts b/src/relay/relay-watcher-root-capacity.test.ts new file mode 100644 index 00000000000..e8434a82011 --- /dev/null +++ b/src/relay/relay-watcher-root-capacity.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_RELAY_WATCH_ROOT_KEY_BYTES, + MAX_RELAY_WATCH_ROOT_KEYS_BYTES, + MAX_RELAY_WATCH_ROOTS, + assertRelayWatcherRootCapacity +} from './relay-watcher-root-capacity' + +function keyWithBytes(label: string, bytes: number): string { + return `${label}${'x'.repeat(bytes - Buffer.byteLength(label))}` +} + +describe('relay watcher root capacity', () => { + it('accepts a root key at the exact UTF-8 byte boundary', () => { + const root = keyWithBytes('/', MAX_RELAY_WATCH_ROOT_KEY_BYTES) + + expect(() => assertRelayWatcherRootCapacity([], [], [], root)).not.toThrow() + }) + + it('rejects a root key one byte over the boundary', () => { + const root = keyWithBytes('/', MAX_RELAY_WATCH_ROOT_KEY_BYTES + 1) + + expect(() => assertRelayWatcherRootCapacity([], [], [], root)).toThrow( + 'File watcher root path is too long' + ) + }) + + it('rejects an oversized raw path even when normalization made its key small', () => { + const rawPath = '/'.repeat(MAX_RELAY_WATCH_ROOT_KEY_BYTES + 1) + + expect(() => assertRelayWatcherRootCapacity([], [], [], '/', rawPath)).toThrow( + 'File watcher root path is too long' + ) + }) + + it('caps aggregate keys retained across active, pending, and failed teardowns', () => { + const retained = Array.from({ length: 4 }, (_, index) => + keyWithBytes(`/${index}/`, MAX_RELAY_WATCH_ROOT_KEY_BYTES - 1) + ) + const exactProspective = keyWithBytes('/p', 4) + expect( + retained.reduce((total, root) => total + Buffer.byteLength(root), 0) + + Buffer.byteLength(exactProspective) + ).toBe(MAX_RELAY_WATCH_ROOT_KEYS_BYTES) + + expect(() => + assertRelayWatcherRootCapacity( + [retained[0]!], + [retained[1]!], + [retained[2]!, retained[3]!], + exactProspective + ) + ).not.toThrow() + expect(() => + assertRelayWatcherRootCapacity( + [retained[0]!], + [retained[1]!], + [retained[2]!, retained[3]!], + `${exactProspective}x` + ) + ).toThrow('Maximum file watcher root path memory reached') + }) + + it('keeps the existing physical watcher count boundary', () => { + const retained = Array.from({ length: MAX_RELAY_WATCH_ROOTS }, (_, index) => `/${index}`) + + expect(() => assertRelayWatcherRootCapacity(retained, [], [], retained[0]!)).not.toThrow() + expect(() => assertRelayWatcherRootCapacity(retained, [], [], '/overflow')).toThrow( + 'Maximum number of file watchers reached' + ) + }) +}) diff --git a/src/relay/relay-watcher-root-capacity.ts b/src/relay/relay-watcher-root-capacity.ts index 025462e7bd3..4f51b1eeafe 100644 --- a/src/relay/relay-watcher-root-capacity.ts +++ b/src/relay/relay-watcher-root-capacity.ts @@ -1,13 +1,37 @@ -const MAX_RELAY_WATCH_ROOTS = 20 +export const MAX_RELAY_WATCH_ROOTS = 20 +export const MAX_RELAY_WATCH_ROOT_KEY_BYTES = 64 * 1024 +export const MAX_RELAY_WATCH_ROOT_KEYS_BYTES = 256 * 1024 + +export function assertRelayWatcherRootKeyCapacity(rootKey: string, rootPath = rootKey): void { + if ( + Buffer.byteLength(rootKey, 'utf8') > MAX_RELAY_WATCH_ROOT_KEY_BYTES || + Buffer.byteLength(rootPath, 'utf8') > MAX_RELAY_WATCH_ROOT_KEY_BYTES + ) { + throw new Error('File watcher root path is too long') + } +} export function assertRelayWatcherRootCapacity( activeRoots: Iterable, pendingRoots: Iterable, teardownRoots: Iterable, - prospectiveRoot: string + prospectiveRoot: string, + prospectiveRootPath = prospectiveRoot ): void { + assertRelayWatcherRootKeyCapacity(prospectiveRoot, prospectiveRootPath) const physicalRoots = new Set([...activeRoots, ...pendingRoots, ...teardownRoots]) physicalRoots.add(prospectiveRoot) + let retainedKeyBytes = 0 + for (const root of physicalRoots) { + const keyBytes = Buffer.byteLength(root, 'utf8') + if (keyBytes > MAX_RELAY_WATCH_ROOT_KEY_BYTES) { + throw new Error('File watcher root path is too long') + } + retainedKeyBytes += keyBytes + if (retainedKeyBytes > MAX_RELAY_WATCH_ROOT_KEYS_BYTES) { + throw new Error('Maximum file watcher root path memory reached') + } + } if (physicalRoots.size > MAX_RELAY_WATCH_ROOTS) { throw new Error('Maximum number of file watchers reached') } diff --git a/src/relay/relay-watcher-teardown-tracker.ts b/src/relay/relay-watcher-teardown-tracker.ts index 91b1460054d..6e250dbbd52 100644 --- a/src/relay/relay-watcher-teardown-tracker.ts +++ b/src/relay/relay-watcher-teardown-tracker.ts @@ -15,6 +15,7 @@ export type RelayWatcherTeardownState = { } export class RelayWatcherTeardownTracker { + // Why: these entries own native handles and cannot be evicted; watcher admission bounds their count and aggregate root-key bytes. private readonly pending = new Map>() private readonly failed = new Map() diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 71e41d605c3..08bbb9f9aad 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -9,10 +9,12 @@ // reconnects via `relay.js --connect`, bridging the new SSH channel's stdio to the existing relay's socket. import { createServer, createConnection, type Socket, type Server } from 'node:net' -import { join } from 'node:path' +import { homedir } from 'node:os' +import { resolve, join } from 'node:path' import { unlinkSync, existsSync, statSync } from 'node:fs' import { RELAY_SENTINEL, + MAX_MESSAGE_SIZE, FrameDecoder, MessageType, encodeJsonRpcFrame, @@ -21,8 +23,8 @@ import { type JsonRpcResponse } from './protocol' import { readLaunchVersion, runConnectHandshake, setupDaemonHandshake } from './relay-handshake' -import { RelayDispatcher } from './dispatcher' -import { RelayContext, expandTilde } from './context' +import { MAX_RELAY_SOCKET_CONNECTIONS, RelayDispatcher } from './dispatcher' +import { RelayContext } from './context' import { PtyHandler } from './pty-handler' import { FsHandler } from './fs-handler' import { installRelayLogRotation } from './rotating-log-writer' @@ -52,6 +54,10 @@ import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' import { registerManagedHookInstaller } from './managed-hook-installer' +import { + NodeReadableTextTooLargeError, + readNodeReadableTextWithinLimit +} from '../shared/node-readable-text' const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 const SOCK_NAME = 'relay.sock' @@ -282,11 +288,14 @@ async function readOrcaCliStdin(): Promise { if (process.stdin.isTTY) { return undefined } - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + try { + return await readNodeReadableTextWithinLimit(process.stdin, MAX_MESSAGE_SIZE) + } catch (error) { + if (error instanceof NodeReadableTextTooLargeError) { + throw new Error(`Remote CLI stdin exceeds ${MAX_MESSAGE_SIZE} byte relay frame limit`) + } + throw error } - return Buffer.concat(chunks).toString('utf8') } // ── Normal mode ────────────────────────────────────────────────────── @@ -376,6 +385,12 @@ async function main(): Promise { return } stdoutDrainWaiters.add(cb) + }, + disconnect: () => { + stdoutAlive = false + flushStdoutDrainWaiters() + process.stdin.destroy() + process.stdout.destroy() } } ) @@ -401,10 +416,13 @@ async function main(): Promise { // Why: `~` is a shell expansion Node's fs APIs don't understand; resolve it to an absolute path on the remote host before persisting. dispatcher.onRequest('session.resolveHome', async (params) => { const inputPath = params.path as string - // Use the shared expander so Windows `~\…` paths resolve too — a remote - // relay host can be Windows, where a literal `~\` would otherwise fall - // through unexpanded and break every downstream fs op. - return { resolvedPath: expandTilde(inputPath) } + if (inputPath === '~' || inputPath === '~/') { + return { resolvedPath: homedir() } + } + if (inputPath.startsWith('~/')) { + return { resolvedPath: resolve(homedir(), inputPath.slice(2)) } + } + return { resolvedPath: inputPath } }) const ptyHandler = new PtyHandler(dispatcher, graceTimeMs) @@ -608,17 +626,6 @@ async function main(): Promise { } function attachAcceptedSocket(sock: Socket, leftover: Buffer): void { - // Why: remove the initial stdin data listener once a socket client is accepted, so stale SSH-channel bytes can't interleave. - process.stdin.pause() - process.stdin.removeAllListeners('data') - - hasAcceptedSocketClient = true - acceptedSocketConnections++ - relayLogLine( - `[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})` - ) - cancelGrace('socket client accepted') - // Why: same backpressure surface as stdout — bulk frames wait for socket drain so they can't bury interactive PTY frames. const sockDrainWaiters = new Set<() => void>() const flushSockDrainWaiters = (): void => { @@ -630,23 +637,44 @@ async function main(): Promise { sock.on('drain', flushSockDrainWaiters) sock.on('close', flushSockDrainWaiters) sock.on('error', flushSockDrainWaiters) - const clientId = dispatcher.attachClient( - (data) => { - if (!sock.destroyed) { - return sock.write(data) - } - return undefined - }, - { - waitWriteDrain: (cb) => { + let clientId: number + try { + clientId = dispatcher.attachClient( + (data) => { if (sock.destroyed) { - cb() - return + return undefined } - sockDrainWaiters.add(cb) + return sock.write(data) + }, + { + waitWriteDrain: (cb) => { + if (sock.destroyed) { + cb() + return + } + sockDrainWaiters.add(cb) + }, + disconnect: () => sock.destroy() } - } + ) + } catch (error) { + relayLogLine( + `[relay] Socket client rejected: ${error instanceof Error ? error.message : String(error)}` + ) + sock.destroy() + return + } + + // Why: remove the initial stdin data listener once a socket client is accepted, so stale SSH-channel bytes can't interleave. + process.stdin.pause() + process.stdin.removeAllListeners('data') + + hasAcceptedSocketClient = true + acceptedSocketConnections++ + relayLogLine( + `[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})` ) + cancelGrace('socket client accepted') socketClients.set(sock, clientId) // Why: feed handshake-buffered leftover bytes before wiring sock.on('data') so frame ordering is preserved. @@ -688,6 +716,7 @@ async function main(): Promise { } }) }) + server.maxConnections = MAX_RELAY_SOCKET_CONNECTIONS // Why: umask 0o177 before listen makes the socket 0o600 atomically, closing the chmod-after-listen TOCTOU window. const shouldSetSocketUmask = !isWindowsNamedPipePath(sockPath) @@ -857,6 +886,7 @@ async function main(): Promise { if (detached) { // Why: detached stdin is /dev/null, so listening would EOF → grace → shutdown before --connect arrives; use the socket instead. stdoutAlive = false + dispatcher.invalidateClient() startGrace('detached startup') } else { process.stdin.on('data', (chunk: Buffer) => { diff --git a/src/relay/workspace-session-handler.test.ts b/src/relay/workspace-session-handler.test.ts index 7c3917e9c2a..40baa7a9155 100644 --- a/src/relay/workspace-session-handler.test.ts +++ b/src/relay/workspace-session-handler.test.ts @@ -1,9 +1,15 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RelayDispatcher } from './dispatcher' -import { WorkspaceSessionHandler } from './workspace-session-handler' +import { + MAX_PRESENCE_CLIENTS_PER_NAMESPACE, + MAX_PRESENCE_NAMESPACES, + MAX_WORKSPACE_SESSION_SNAPSHOT_BYTES, + MAX_WORKSPACE_SESSION_SNAPSHOT_STRUCTURAL_TOKENS, + WorkspaceSessionHandler +} from './workspace-session-handler' import { encodeJsonRpcFrame, MessageType, type JsonRpcRequest } from './protocol' function decodeJsonFrames(written: Buffer[]): unknown[] { @@ -34,6 +40,7 @@ async function sendRequest( describe('WorkspaceSessionHandler', () => { let baseDir: string let dispatcher: RelayDispatcher + let handler: WorkspaceSessionHandler let written: Buffer[] beforeEach(() => { @@ -42,11 +49,12 @@ describe('WorkspaceSessionHandler', () => { dispatcher = new RelayDispatcher((data) => { written.push(Buffer.from(data)) }) - new WorkspaceSessionHandler(dispatcher, baseDir) + handler = new WorkspaceSessionHandler(dispatcher, baseDir) }) afterEach(() => { dispatcher.dispose() + vi.useRealTimers() rmSync(baseDir, { recursive: true, force: true }) }) @@ -104,6 +112,40 @@ describe('WorkspaceSessionHandler', () => { expect(staleResponse.result.snapshot.revision).toBe(1) }) + it('starts fresh when a persisted snapshot exceeds the file cap', async () => { + const snapshotPath = join(baseDir, 'oversized.json') + const file = openSync(snapshotPath, 'w') + ftruncateSync(file, MAX_WORKSPACE_SESSION_SNAPSHOT_BYTES + 1) + closeSync(file) + + await sendRequest(dispatcher, 'workspace.get', { namespace: 'oversized' }, 1) + + const response = decodeJsonFrames(written).find( + (frame) => (frame as { id?: number }).id === 1 + ) as { result: { revision: number; session: { activeRepoId: unknown } } } + expect(response.result.revision).toBe(0) + expect(response.result.session.activeRepoId).toBeNull() + }) + + it('starts fresh when a bounded snapshot amplifies structure before parsing', () => { + writeFileSync( + join(baseDir, 'amplified.json'), + `[${'0,'.repeat(MAX_WORKSPACE_SESSION_SNAPSHOT_STRUCTURAL_TOKENS)}0]` + ) + const parseSpy = vi.spyOn(JSON, 'parse') + + const snapshot = ( + handler as unknown as { + read(namespace: string): { revision: number; session: { activeRepoId: unknown } } + } + ).read('amplified') + + expect(snapshot.revision).toBe(0) + expect(snapshot.session.activeRepoId).toBeNull() + expect(parseSpy).not.toHaveBeenCalled() + parseSpy.mockRestore() + }) + it('tracks presence per namespace', async () => { await sendRequest( dispatcher, @@ -139,4 +181,83 @@ describe('WorkspaceSessionHandler', () => { 'Laptop A' ) }) + + it('does not retain namespaces for presence queries without a client id', async () => { + for (let index = 0; index < MAX_PRESENCE_NAMESPACES + 10; index += 1) { + await sendRequest( + dispatcher, + 'workspace.presence', + { namespace: `query-${index}` }, + index + 1 + ) + } + + const namespaces = (handler as unknown as { clientsByNamespace: Map }) + .clientsByNamespace + expect(namespaces.size).toBe(0) + }) + + it('sweeps expired clients from every namespace on any heartbeat', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + await sendRequest(dispatcher, 'workspace.presence', { namespace: 'a', clientId: 'a' }, 1) + await sendRequest(dispatcher, 'workspace.presence', { namespace: 'b', clientId: 'b' }, 2) + + vi.setSystemTime(new Date('2026-01-01T00:01:00Z')) + await sendRequest(dispatcher, 'workspace.presence', { namespace: 'c', clientId: 'c' }, 3) + + const namespaces = (handler as unknown as { clientsByNamespace: Map }) + .clientsByNamespace + expect(Array.from(namespaces.keys())).toEqual(['c']) + }) + + it('bounds clients per namespace by evicting the oldest heartbeat', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + for (let index = 0; index <= MAX_PRESENCE_CLIENTS_PER_NAMESPACE; index += 1) { + vi.setSystemTime(Date.now() + 1) + await sendRequest( + dispatcher, + 'workspace.presence', + { namespace: 'team', clientId: `client-${String(index).padStart(3, '0')}` }, + index + 1 + ) + } + + const clients = ( + handler as unknown as { + clientsByNamespace: Map> + } + ).clientsByNamespace.get('team') + expect(clients?.size).toBe(MAX_PRESENCE_CLIENTS_PER_NAMESPACE) + expect(clients?.has('client-000')).toBe(false) + expect( + clients?.has(`client-${String(MAX_PRESENCE_CLIENTS_PER_NAMESPACE).padStart(3, '0')}`) + ).toBe(true) + }) + + it('bounds namespaces by evicting the least recently active one', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + for (let index = 0; index <= MAX_PRESENCE_NAMESPACES; index += 1) { + vi.setSystemTime(Date.now() + 1) + await sendRequest( + dispatcher, + 'workspace.presence', + { + namespace: `space-${String(index).padStart(3, '0')}`, + clientId: `client-${index}` + }, + index + 1 + ) + } + + const namespaces = (handler as unknown as { clientsByNamespace: Map }) + .clientsByNamespace + expect(namespaces.size).toBe(MAX_PRESENCE_NAMESPACES) + expect(namespaces.has('space-000')).toBe(false) + expect(namespaces.has(`space-${MAX_PRESENCE_NAMESPACES}`)).toBe(true) + }) }) + +type ConnectedClientForTest = { clientId: string; name: string; lastSeenAt: number } diff --git a/src/relay/workspace-session-handler.ts b/src/relay/workspace-session-handler.ts index c87a8b6076f..5ec419128d2 100644 --- a/src/relay/workspace-session-handler.ts +++ b/src/relay/workspace-session-handler.ts @@ -1,6 +1,9 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' +import { assertJsonTextStructureWithinLimits } from '../shared/memory-safety/json-text-structure-limit' +import { readNodeFileSyncWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import { stringifyJsonWithinByteLimit } from '../shared/memory-safety/node-bounded-json-stringify' import type { RelayDispatcher } from './dispatcher' type RemoteWorkspaceSnapshot = { @@ -28,6 +31,11 @@ type PatchResult = const SNAPSHOT_SCHEMA_VERSION = 1 const PRESENCE_TTL_MS = 45_000 +export const MAX_PRESENCE_NAMESPACES = 256 +export const MAX_PRESENCE_CLIENTS_PER_NAMESPACE = 64 +export const MAX_WORKSPACE_SESSION_SNAPSHOT_BYTES = 16 * 1024 * 1024 +export const MAX_WORKSPACE_SESSION_SNAPSHOT_STRUCTURAL_TOKENS = 1_000_000 +export const MAX_WORKSPACE_SESSION_SNAPSHOT_NESTING_DEPTH = 128 function emptySession(): Record { return { @@ -81,7 +89,15 @@ export class WorkspaceSessionHandler { } try { - const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial + const content = readNodeFileSyncWithinLimit( + path, + MAX_WORKSPACE_SESSION_SNAPSHOT_BYTES + ).buffer.toString('utf8') + assertJsonTextStructureWithinLimits(content, { + structuralTokens: MAX_WORKSPACE_SESSION_SNAPSHOT_STRUCTURAL_TOKENS, + nestingDepth: MAX_WORKSPACE_SESSION_SNAPSHOT_NESTING_DEPTH + }) + const parsed = JSON.parse(content) as Partial return { namespace, revision: @@ -116,7 +132,11 @@ export class WorkspaceSessionHandler { const path = this.snapshotPath(snapshot.namespace) mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) const tmpPath = `${path}.tmp` - writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2), { mode: 0o600 }) + const serialized = stringifyJsonWithinByteLimit( + snapshot, + MAX_WORKSPACE_SESSION_SNAPSHOT_BYTES + ).serialized + writeFileSync(tmpPath, serialized, { mode: 0o600 }) renameSync(tmpPath, path) } @@ -163,25 +183,72 @@ export class WorkspaceSessionHandler { const namespace = sanitizeNamespace(params.namespace) const clientId = typeof params.clientId === 'string' ? sanitizeClientId(params.clientId) : '' const name = typeof params.clientName === 'string' ? sanitizeClientName(params.clientName) : '' - const clients = this.clientsByNamespace.get(namespace) ?? new Map() - this.clientsByNamespace.set(namespace, clients) - const now = Date.now() - for (const [id, client] of clients) { - if (now - client.lastSeenAt > PRESENCE_TTL_MS) { - clients.delete(id) + this.sweepPresence(now) + + let clients = this.clientsByNamespace.get(namespace) + if (!clientId) { + return { clients: this.sortedClients(clients) } + } + if (!clients) { + if (this.clientsByNamespace.size >= MAX_PRESENCE_NAMESPACES) { + this.evictOldestNamespace() + } + clients = new Map() + this.clientsByNamespace.set(namespace, clients) + } + if (!clients.has(clientId) && clients.size >= MAX_PRESENCE_CLIENTS_PER_NAMESPACE) { + this.evictOldestClient(clients) + } + clients.set(clientId, { + clientId, + name: name || 'Unknown device', + lastSeenAt: now + }) + + return { clients: this.sortedClients(clients) } + } + + private sweepPresence(now: number): void { + for (const [namespace, clients] of this.clientsByNamespace) { + for (const [id, client] of clients) { + if (now - client.lastSeenAt > PRESENCE_TTL_MS) { + clients.delete(id) + } + } + if (clients.size === 0) { + this.clientsByNamespace.delete(namespace) } } - if (clientId) { - clients.set(clientId, { - clientId, - name: name || 'Unknown device', - lastSeenAt: now - }) - } + } - return { - clients: Array.from(clients.values()).sort((a, b) => b.lastSeenAt - a.lastSeenAt) + private evictOldestNamespace(): void { + let oldest: { namespace: string; lastSeenAt: number } | undefined + for (const [namespace, clients] of this.clientsByNamespace) { + const lastSeenAt = Math.max(...Array.from(clients.values(), (client) => client.lastSeenAt)) + if ( + !oldest || + lastSeenAt < oldest.lastSeenAt || + (lastSeenAt === oldest.lastSeenAt && namespace < oldest.namespace) + ) { + oldest = { namespace, lastSeenAt } + } + } + if (oldest) { + this.clientsByNamespace.delete(oldest.namespace) } } + + private evictOldestClient(clients: Map): void { + const oldest = Array.from(clients.values()).sort( + (a, b) => a.lastSeenAt - b.lastSeenAt || a.clientId.localeCompare(b.clientId) + )[0] + if (oldest) { + clients.delete(oldest.clientId) + } + } + + private sortedClients(clients: Map | undefined): ConnectedClient[] { + return Array.from(clients?.values() ?? []).sort((a, b) => b.lastSeenAt - a.lastSeenAt) + } } diff --git a/src/relay/workspace-space-scan.ts b/src/relay/workspace-space-scan.ts index cc823457142..d2ffb8af378 100644 --- a/src/relay/workspace-space-scan.ts +++ b/src/relay/workspace-space-scan.ts @@ -2,16 +2,25 @@ cancellation, symlink, and top-level compaction semantics in one scanner. */ import { execFile } from 'node:child_process' import type { Dirent } from 'node:fs' -import { lstat, readdir } from 'node:fs/promises' +import { lstat, opendir } from 'node:fs/promises' import { basename, join } from 'node:path' import { platform } from 'node:process' import { promisify } from 'node:util' import type { WorkspaceSpaceDirectoryScanResult, - WorkspaceSpaceItem, - WorkspaceSpaceItemKind + WorkspaceSpaceItem } from '../shared/workspace-space-types' import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' +import { mapWithConcurrency } from '../shared/map-with-concurrency' +import { + scanWorkspaceSpaceEntryTree, + type WorkspaceSpaceEntryScan +} from '../shared/workspace-space-entry-traversal' +import { + collectWorkspaceSpaceDirectoryEntries, + createWorkspaceSpaceScanBudget, + WorkspaceSpaceScanCapacityError +} from '../shared/workspace-space-scan-budget' import type { RequestContext } from './dispatcher' const RELAY_FS_CONCURRENCY = 48 @@ -19,15 +28,7 @@ const DU_TIMEOUT_MS = 120_000 const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 const execFileAsync = promisify(execFile) -type AsyncLimiter = (task: () => Promise) => Promise - -type ScanStats = { - name: string - path: string - kind: WorkspaceSpaceItemKind - sizeBytes: number - skippedEntryCount: number -} +type ScanStats = WorkspaceSpaceEntryScan class RelayWorkspaceSpaceScanCancelledError extends Error { constructor() { @@ -42,57 +43,6 @@ function throwIfCancelled(context: RequestContext): void { } } -function createAsyncLimiter(maxConcurrent: number, context: RequestContext): AsyncLimiter { - let active = 0 - const queue: { resolve: () => void }[] = [] - - const acquire = async (): Promise => { - throwIfCancelled(context) - if (active < maxConcurrent) { - active += 1 - return - } - await new Promise((resolve, reject) => { - let onAbort: (() => void) | null = null - const waiter = { - resolve: () => { - if (onAbort) { - context.signal?.removeEventListener('abort', onAbort) - } - resolve() - } - } - onAbort = () => { - const index = queue.indexOf(waiter) - if (index !== -1) { - queue.splice(index, 1) - } - reject(new RelayWorkspaceSpaceScanCancelledError()) - } - queue.push(waiter) - if (context.signal) { - context.signal.addEventListener('abort', onAbort, { once: true }) - if (context.signal.aborted) { - onAbort() - } - } - }) - throwIfCancelled(context) - active += 1 - } - - return async (task: () => Promise): Promise => { - await acquire() - try { - return await task() - } finally { - active -= 1 - const next = queue.shift() - next?.resolve() - } - } -} - function normalizeDuPath(pathValue: string): string { const trimmed = pathValue.replace(/\/+$/, '') return trimmed.length > 0 ? trimmed : pathValue @@ -142,11 +92,10 @@ async function scanTopLevelEntryWithDu( entryPath: string, name: string, duSizes: Map, - limit: AsyncLimiter, context: RequestContext ): Promise { throwIfCancelled(context) - const stats = await limit(() => lstat(entryPath)) + const stats = await lstat(entryPath) throwIfCancelled(context) if (stats.isSymbolicLink()) { @@ -181,77 +130,30 @@ async function scanTopLevelEntryWithDu( async function scanEntryAggregate( entryPath: string, name: string, - limit: AsyncLimiter, context: RequestContext ): Promise { - throwIfCancelled(context) - const stats = await limit(() => lstat(entryPath)) - throwIfCancelled(context) - - if (stats.isSymbolicLink()) { - return { - name, - path: entryPath, - kind: 'symlink', - sizeBytes: stats.size, - skippedEntryCount: 0 - } - } - - if (!stats.isDirectory()) { - return { - name, - path: entryPath, - kind: 'file', - sizeBytes: stats.size, - skippedEntryCount: 0 - } - } - - let entries: Dirent[] - try { - entries = await limit(() => readdir(entryPath, { withFileTypes: true })) - } catch { - return { - name, - path: entryPath, - kind: 'directory', - sizeBytes: stats.size, - skippedEntryCount: 1 - } - } - - const childStats = await Promise.all( - entries.map(async (entry): Promise => { - try { - return await scanEntryAggregate(join(entryPath, entry.name), entry.name, limit, context) - } catch (error) { - if (error instanceof RelayWorkspaceSpaceScanCancelledError) { - throw error - } - return null + return scanWorkspaceSpaceEntryTree({ + rootPath: entryPath, + rootName: name, + concurrency: RELAY_FS_CONCURRENCY, + signal: context.signal, + entryName: (entry) => entry.name, + joinPath: join, + classifyEntry: async (path) => { + const stats = await lstat(path) + throwIfCancelled(context) + if (stats.isSymbolicLink()) { + return { kind: 'symlink', sizeBytes: stats.size } } - }) - ) - - let sizeBytes = stats.size - let skippedEntryCount = 0 - for (const child of childStats) { - if (!child) { - skippedEntryCount += 1 - continue - } - sizeBytes += child.sizeBytes - skippedEntryCount += child.skippedEntryCount - } - - return { - name, - path: entryPath, - kind: 'directory', - sizeBytes, - skippedEntryCount - } + return stats.isDirectory() + ? { kind: 'directory', sizeBytes: stats.size } + : { kind: 'file', sizeBytes: stats.size } + }, + readDirectory: (path) => opendir(path), + checkCancelled: () => throwIfCancelled(context), + createCancellationError: () => new RelayWorkspaceSpaceScanCancelledError(), + isCancellationError: (error) => error instanceof RelayWorkspaceSpaceScanCancelledError + }) } async function scanDirectoryWithDu( @@ -266,19 +168,27 @@ async function scanDirectoryWithDu( } const [entries, duSizes] = await Promise.all([ - readdir(rootPath, { withFileTypes: true }), + opendir(rootPath).then((directory) => + collectWorkspaceSpaceDirectoryEntries( + directory, + rootPath, + (entry) => entry.name, + createWorkspaceSpaceScanBudget(), + () => throwIfCancelled(context) + ) + ), readDuDepthOne(rootPath, context) ]) throwIfCancelled(context) - const limit = createAsyncLimiter(RELAY_FS_CONCURRENCY, context) - const childStats = await Promise.all( - entries.map(async (entry): Promise => { + const childStats = await mapWithConcurrency( + entries, + RELAY_FS_CONCURRENCY, + async (entry): Promise => { try { return await scanTopLevelEntryWithDu( join(rootPath, entry.name), entry.name, duSizes, - limit, context ) } catch (error) { @@ -287,7 +197,7 @@ async function scanDirectoryWithDu( } return null } - }) + } ) const children = childStats.filter((child): child is ScanStats => child !== null) const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) @@ -305,55 +215,13 @@ async function scanDirectoryWithNode( rootPath: string, context: RequestContext ): Promise { - throwIfCancelled(context) - const limit = createAsyncLimiter(RELAY_FS_CONCURRENCY, context) - const rootStats = await lstat(rootPath) - throwIfCancelled(context) - if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { - const root = await scanEntryAggregate(rootPath, basename(rootPath), limit, context) - return { - sizeBytes: root.sizeBytes, - skippedEntryCount: root.skippedEntryCount, - topLevelItems: [], - omittedTopLevelItemCount: 0, - omittedTopLevelSizeBytes: 0 - } - } - - let entries: Dirent[] - try { - entries = await readdir(rootPath, { withFileTypes: true }) - } catch { - return { - sizeBytes: rootStats.size, - skippedEntryCount: 1, - topLevelItems: [], - omittedTopLevelItemCount: 0, - omittedTopLevelSizeBytes: 0 - } - } - - const childStats = await Promise.all( - entries.map(async (entry): Promise => { - try { - return await scanEntryAggregate(join(rootPath, entry.name), entry.name, limit, context) - } catch (error) { - if (error instanceof RelayWorkspaceSpaceScanCancelledError) { - throw error - } - return null - } - }) - ) - const children = childStats.filter((child): child is ScanStats => child !== null) + const root = await scanEntryAggregate(rootPath, basename(rootPath), context) + const children = root.children ?? [] const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) return { - sizeBytes: rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0), - skippedEntryCount: - children.reduce((sum, child) => sum + child.skippedEntryCount, 0) + - childStats.length - - children.length, + sizeBytes: root.sizeBytes, + skippedEntryCount: root.skippedEntryCount, ...compact } } @@ -366,7 +234,10 @@ export async function scanWorkspaceSpaceDirectory( try { return await scanDirectoryWithDu(rootPath, context) } catch (error) { - if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + if ( + error instanceof RelayWorkspaceSpaceScanCancelledError || + error instanceof WorkspaceSpaceScanCapacityError + ) { throw error } } diff --git a/src/relay/wsl-hook-fs-bridge.test.ts b/src/relay/wsl-hook-fs-bridge.test.ts index f656b4aa3bd..8ca16770b41 100644 --- a/src/relay/wsl-hook-fs-bridge.test.ts +++ b/src/relay/wsl-hook-fs-bridge.test.ts @@ -2,13 +2,14 @@ // `posix.resolve` against a POSIX guest home. On win32 `posix.resolve` of a // Windows tmpdir yields an invalid path, so the whole suite is skipped there // (the bridge only ever runs inside a Linux WSL guest). -import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' import { posix } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge' import type { MethodHandler, RelayDispatcher, RequestContext } from './dispatcher' +import { estimateFilesystemDirectoryEntryBytes } from '../shared/filesystem-directory-listing-limit' import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../shared/wsl-hook-relay-contract' describe.skipIf(process.platform === 'win32')('registerWslHookFsHandlers (WSL fs bridge)', () => { @@ -57,6 +58,22 @@ describe.skipIf(process.platform === 'win32')('registerWslHookFsHandlers (WSL fs expect(read).toEqual({ ok: true, content: 'hello guest' }) }) + it('accepts an exact byte-limit read and rejects an oversized file before materializing it', async () => { + const path = posix.join(home, 'bounded.txt') + writeFileSync(path, '🐋') + + await expect( + call<{ content: string }>(WSL_HOOK_FS_METHODS.readFile, { path, maxBytes: 4 }) + ).resolves.toEqual({ ok: true, content: '🐋' }) + await expect( + call<{ content: string }>(WSL_HOOK_FS_METHODS.readFile, { path, maxBytes: 3 }) + ).resolves.toMatchObject({ + ok: false, + errno: 'EFBIG', + fileCapacity: { observedBytes: 4, maxBytes: 3 } + }) + }) + it('refuses writeFile to an absolute path outside home', async () => { const result = await call(WSL_HOOK_FS_METHODS.writeFile, { path: '/etc/orca-evil.txt', @@ -100,6 +117,48 @@ describe.skipIf(process.platform === 'win32')('registerWslHookFsHandlers (WSL fs expect(root.ok).toBe(true) }) + it('streams directory entries through requested entry and retained-byte limits', async () => { + const directory = posix.join(home, 'bounded-dir') + mkdirSync(directory) + writeFileSync(posix.join(directory, 'alpha'), '') + writeFileSync(posix.join(directory, 'beta'), '') + + const exactEntries = await call<{ entries: { filename: string }[] }>( + WSL_HOOK_FS_METHODS.readdir, + { path: directory, maxEntries: 2, maxRetainedBytes: 4096 } + ) + expect(exactEntries).toMatchObject({ ok: true }) + if (exactEntries.ok) { + expect(exactEntries.entries).toHaveLength(2) + } + await expect( + call(WSL_HOOK_FS_METHODS.readdir, { + path: directory, + maxEntries: 1, + maxRetainedBytes: 4096 + }) + ).resolves.toMatchObject({ ok: false }) + + const single = posix.join(home, 'single-entry-dir') + mkdirSync(single) + writeFileSync(posix.join(single, 'one'), '') + const exactRetainedBytes = estimateFilesystemDirectoryEntryBytes({ name: 'one' }) + await expect( + call(WSL_HOOK_FS_METHODS.readdir, { + path: single, + maxEntries: 1, + maxRetainedBytes: exactRetainedBytes + }) + ).resolves.toMatchObject({ ok: true }) + await expect( + call(WSL_HOOK_FS_METHODS.readdir, { + path: single, + maxEntries: 1, + maxRetainedBytes: exactRetainedBytes - 1 + }) + ).resolves.toMatchObject({ ok: false }) + }) + it('refuses readdir on a non-ancestor dir outside home', async () => { const result = await call(WSL_HOOK_FS_METHODS.readdir, { path: '/etc' }) expect(result).toMatchObject({ ok: false, errno: 'EACCES' }) diff --git a/src/relay/wsl-hook-fs-bridge.ts b/src/relay/wsl-hook-fs-bridge.ts index 3ebb6047b1b..d0eaff4f7c5 100644 --- a/src/relay/wsl-hook-fs-bridge.ts +++ b/src/relay/wsl-hook-fs-bridge.ts @@ -7,17 +7,54 @@ import { promises as fs } from 'node:fs' import { posix } from 'node:path' import type { RelayDispatcher } from './dispatcher' +import { + createFilesystemDirectoryLimitState, + trackFilesystemDirectoryEntry +} from '../shared/filesystem-directory-listing-limit' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../shared/memory-safety/node-bounded-file-reader' import { WSL_HOOK_FS_METHODS, + WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES, + WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES, + WSL_HOOK_FS_MAX_READ_BYTES, type WslFsFailure, type WslFsResult } from '../shared/wsl-hook-relay-contract' function failure(err: unknown): WslFsFailure { + if (err instanceof NodeFileReadTooLargeError) { + return { + ok: false, + errno: 'EFBIG', + message: err.message, + fileCapacity: { observedBytes: err.observedBytes, maxBytes: err.maxBytes } + } + } const e = err as NodeJS.ErrnoException return { ok: false, errno: e?.code ?? 'EUNKNOWN', message: e?.message ?? String(err) } } +function requestedLimit(value: unknown, maximum: number): number { + if (value === undefined) { + return maximum + } + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw Object.assign(new Error('invalid capacity limit'), { code: 'EINVAL' }) + } + return Math.min(value, maximum) +} + +function requestedDirectoryLimit(value: unknown, maximum: number): number { + const limit = requestedLimit(value, maximum) + if (limit === 0) { + throw Object.assign(new Error('directory capacity limit must be positive'), { code: 'EINVAL' }) + } + return limit +} + export function registerWslHookFsHandlers( dispatcher: RelayDispatcher, home: string, @@ -46,7 +83,7 @@ export function registerWslHookFsHandlers( return resolved } // Why: the installers' mkdir-p walks top-down from `/`, probing every - // ancestor of home with readdir before it ever creates a dir. Allow + // ancestor of home before it ever creates a dir. Allow // read-only existence probes on those ancestors; everything else stays // home-scoped. const scopedProbe = (rawPath: unknown): string => { @@ -68,7 +105,10 @@ export function registerWslHookFsHandlers( WSL_HOOK_FS_METHODS.readFile, async (params): Promise> => { try { - const content = await fs.readFile(scoped(params.path), 'utf8') + const maxBytes = requestedLimit(params.maxBytes, WSL_HOOK_FS_MAX_READ_BYTES) + const content = ( + await readNodeFileWithinLimit(scoped(params.path), maxBytes) + ).buffer.toString('utf8') return { ok: true, content } } catch (err) { return failure(err) @@ -93,7 +133,7 @@ export function registerWslHookFsHandlers( WSL_HOOK_FS_METHODS.stat, async (params): Promise> => { try { - const stats = await fs.stat(scoped(params.path)) + const stats = await fs.stat(scopedProbe(params.path)) return { ok: true, mode: stats.mode } } catch (err) { return failure(err) @@ -133,11 +173,39 @@ export function registerWslHookFsHandlers( dispatcher.onRequest( WSL_HOOK_FS_METHODS.readdir, async (params): Promise> => { + let directory: Awaited> | undefined try { - const names = await fs.readdir(scopedProbe(params.path)) - return { ok: true, entries: names.map((filename) => ({ filename })) } + const limits = { + maxEntries: requestedDirectoryLimit(params.maxEntries, WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES), + maxRetainedBytes: requestedDirectoryLimit( + params.maxRetainedBytes, + WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES + ) + } + const limit = createFilesystemDirectoryLimitState(limits) + const entries: { filename: string }[] = [] + const directoryPath = scopedProbe(params.path) + directory = await fs.opendir(directoryPath, { bufferSize: 32 }) + // Ancestors outside home are authorized only as existence probes. + if ( + directoryPath !== homeRoot && + (directoryPath === '/' || homeRoot.startsWith(`${directoryPath}/`)) + ) { + return { ok: true, entries } + } + for (;;) { + const entry = await directory.read() + if (entry === null) { + break + } + trackFilesystemDirectoryEntry(limit, { name: entry.name }) + entries.push({ filename: entry.name }) + } + return { ok: true, entries } } catch (err) { return failure(err) + } finally { + await directory?.close().catch(() => undefined) } } ) diff --git a/src/relay/wsl-install-plugins-handler.test.ts b/src/relay/wsl-install-plugins-handler.test.ts new file mode 100644 index 00000000000..be3340009bd --- /dev/null +++ b/src/relay/wsl-install-plugins-handler.test.ts @@ -0,0 +1,193 @@ +// POSIX-only: the guest relay runs inside the Linux distro and materializes +// overlays under a real $HOME. On a Windows dev host tmpdir() yields C:\ paths +// the overlay logic is not meant to serve; live coverage comes from the rig. +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { PluginOverlayManager } from './plugin-overlay' +import { createInstallPluginsHandler } from './wsl-install-plugins-handler' +import { PLUGIN_SOURCE_MAX_BYTES } from './plugin-source-limit' + +describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (guest side)', () => { + function freshHome(): string { + return mkdtempSync(join(tmpdir(), 'wsl-guest-home-')) + } + + function withHome(run: (home: string) => void): void { + const home = freshHome() + try { + run(home) + } finally { + rmSync(home, { recursive: true, force: true }) + } + } + + it('writes orca-opencode-status.js into the overlay and returns that dir', () => { + withHome((home) => { + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + ORCA_WSL_HOOK_INSTANCE: 'inst1' + } as NodeJS.ProcessEnv) + const source = '// orca opencode status plugin\nexport const Plugin = () => ({})\n' + const res = install({ opencodePluginSource: source }) + + expect(res.installed.opencode).toBe(true) + const dir = res.overlayDirs.opencode + expect(typeof dir).toBe('string') + const pluginPath = join(dir as string, 'plugins', 'orca-opencode-status.js') + expect(existsSync(pluginPath)).toBe(true) + expect(readFileSync(pluginPath, 'utf8')).toBe(source) + }) + }) + + it('reuses the overlay on repeat installs instead of rebuilding it', () => { + withHome((home) => { + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + ORCA_WSL_HOOK_INSTANCE: 'inst1' + } as NodeJS.ProcessEnv) + const source = '// v1\n' + const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string + + // Why: a wipe-and-rebuild would delete this alongside the rest of the tree, + // pulling the config root out from under an agent already running against it. + const canary = join(dir, 'opencode.json') + writeFileSync(canary, '{"model":"user-set"}') + + // The host re-ships on every reinstall (60s one-shot, later pane spawns). + expect(install({ opencodePluginSource: source }).overlayDirs.opencode).toBe(dir) + expect(install({}).overlayDirs.opencode).toBe(dir) + expect(existsSync(canary)).toBe(true) + }) + }) + + it('rebuilds if the resolved source dir ever changes (defensive)', () => { + withHome((home) => { + // The relay's env is fixed for its lifetime, so nothing in production reaches + // this branch today; it exists so a plugin-only overlay can't outlive a source + // dir becoming resolvable. Simulated by mutating the env the factory captured. + const userConfig = join(home, 'my-opencode') + const env = { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' } as NodeJS.ProcessEnv + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), env) + const source = '// v1\n' + install({ opencodePluginSource: source }) + + mkdirSync(userConfig, { recursive: true }) + writeFileSync(join(userConfig, 'opencode.json'), '{"model":"late"}') + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = userConfig + + const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string + expect(readFileSync(join(dir, 'opencode.json'), 'utf8')).toBe('{"model":"late"}') + }) + }) + + it('rebuilds when the cached overlay lost its plugin file', () => { + withHome((home) => { + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + ORCA_WSL_HOOK_INSTANCE: 'inst1' + } as NodeJS.ProcessEnv) + const source = '// v1\n' + const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string + // Why: a rebuild that failed after the wipe leaves the dir but not the plugin; + // an existsSync on the dir alone would call that a cache hit forever. + rmSync(join(dir, 'plugins', 'orca-opencode-status.js')) + + expect(install({ opencodePluginSource: source }).overlayDirs.opencode).toBe(dir) + expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true) + }) + }) + + it('re-materializes when the shipped source changes', () => { + withHome((home) => { + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + ORCA_WSL_HOOK_INSTANCE: 'inst1' + } as NodeJS.ProcessEnv) + install({ opencodePluginSource: '// v1\n' }) + // Why: a mid-session Orca upgrade ships new plugin source; future spawns must see it. + const dir = install({ opencodePluginSource: '// v2\n' }).overlayDirs.opencode as string + expect(readFileSync(join(dir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe('// v2\n') + }) + }) + + it('rebuilds when the cached overlay disappeared from the guest', () => { + withHome((home) => { + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + ORCA_WSL_HOOK_INSTANCE: 'inst1' + } as NodeJS.ProcessEnv) + const source = '// v1\n' + const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string + rmSync(dir, { recursive: true, force: true }) + + expect(install({ opencodePluginSource: source }).overlayDirs.opencode).toBe(dir) + expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true) + }) + }) + + it('mirrors an explicitly-set config root so overriding the var does not drop it', () => { + withHome((home) => { + // Why: setting OPENCODE_CONFIG_DIR to the overlay removes the user's own value + // from OpenCode's config-dir list, so that one must be mirrored in. + const userConfig = join(home, 'my-opencode') + mkdirSync(userConfig, { recursive: true }) + writeFileSync(join(userConfig, 'opencode.json'), '{"model":"user-set"}') + + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + ORCA_OPENCODE_SOURCE_CONFIG_DIR: userConfig, + ORCA_WSL_HOOK_INSTANCE: 'inst1' + } as NodeJS.ProcessEnv) + const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string + + expect(readFileSync(join(dir, 'opencode.json'), 'utf8')).toBe('{"model":"user-set"}') + expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true) + }) + }) + + it('does not mirror the XDG default config root', () => { + withHome((home) => { + // Why: OpenCode APPENDS OPENCODE_CONFIG_DIR to its config-dir list rather than + // replacing it, so ~/.config/opencode is read anyway — mirroring it here would + // load the user's config and plugins twice. + const defaultConfig = join(home, '.config', 'opencode') + mkdirSync(defaultConfig, { recursive: true }) + writeFileSync(join(defaultConfig, 'opencode.json'), '{"model":"default"}') + + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + ORCA_WSL_HOOK_INSTANCE: 'inst1' + } as NodeJS.ProcessEnv) + const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string + + expect(existsSync(join(dir, 'opencode.json'))).toBe(false) + expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true) + }) + }) + + it('rejects a source that exceeds the byte cap before writing anything', () => { + withHome((home) => { + const overlay = new PluginOverlayManager({ homeDir: home }) + const install = createInstallPluginsHandler(overlay, { + HOME: home + } as NodeJS.ProcessEnv) + const tooBig = 'a'.repeat(PLUGIN_SOURCE_MAX_BYTES + 1) + expect(() => install({ opencodePluginSource: tooBig })).toThrow(/byte cap/) + expect(overlay.hasOpenCodeSource()).toBe(false) + }) + }) + + it('returns no overlay dir when no opencode source is provided', () => { + withHome((home) => { + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home + } as NodeJS.ProcessEnv) + const res = install({}) + expect(res.installed.opencode).toBe(false) + expect(res.overlayDirs.opencode).toBeUndefined() + }) + }) +}) diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts new file mode 100644 index 00000000000..9bfd5e17009 --- /dev/null +++ b/src/relay/wsl-install-plugins-handler.ts @@ -0,0 +1,89 @@ +// Guest-side handler for AGENT_HOOK_INSTALL_PLUGINS_METHOD: caches the plugin +// source the Windows host ships over the wire and materializes OpenCode's +// config overlay inside the guest. Extracted from the relay entrypoint so it is +// unit-testable without binding the hook server. Scope is OpenCode only for +// now; the payload/response shape matches the SSH relay so Pi/OMP are additive. +import { existsSync } from 'node:fs' + +import { getRelayOpenCodePluginPath, type PluginOverlayManager } from './plugin-overlay' +import { resolveOpenCodeSourceConfigDir } from './plugin-overlay-env' +import { assertPluginSourceUnderByteCap } from './plugin-source-limit' +import { + sanitizeWslHookInstanceKey, + WSL_HOOK_RELAY_INSTANCE_ENV +} from '../shared/wsl-hook-relay-contract' + +export type InstallPluginsResult = { + installed: { opencode: boolean; pi: boolean; omp: boolean } + overlayDirs: { opencode?: string } +} + +export type InstallPluginsHandler = (params: Record) => InstallPluginsResult + +// Why NOT to fall back to ~/.config/opencode here: OpenCode APPENDS +// OPENCODE_CONFIG_DIR to its config-dir list, it does not replace it — the +// XDG default is always read too. Mirroring the default into the overlay would +// load the user's config (and plugins) twice. Only an explicitly-set dir is +// mirrored, because that one leaves the list when we override the var. +export function createInstallPluginsHandler( + pluginOverlay: PluginOverlayManager, + env: NodeJS.ProcessEnv +): InstallPluginsHandler { + // Why: materializeOpenCode wipes and rebuilds the overlay, and the id here is + // instance-scoped (not pane-scoped as on SSH). The host re-ships on every + // reinstall — 60s after connect and again on later pane spawns — so + // re-materializing unconditionally would delete the config root out from + // under running agents and race panes spawning against the path the host just + // handed them. Rebuild only when the shipped source actually changed. + let materialized: { source: string; sourceDir: string | undefined; dir: string } | null = null + + return (params) => { + const opencode = params.opencodePluginSource + const pi = params.piExtensionSource + const omp = params.ompExtensionSource + // Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay. + assertPluginSourceUnderByteCap('opencodePluginSource', opencode) + assertPluginSourceUnderByteCap('piExtensionSource', pi) + assertPluginSourceUnderByteCap('ompExtensionSource', omp) + pluginOverlay.setSources({ + opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, + piExtensionSource: typeof pi === 'string' ? pi : undefined, + ompExtensionSource: typeof omp === 'string' ? omp : undefined + }) + let opencodeDir: string | undefined + if (pluginOverlay.hasOpenCodeSource()) { + // An omitted source leaves the manager's cache untouched, so it counts as unchanged. + const incoming = typeof opencode === 'string' ? opencode : null + // Explicit-only (see header). Constant in practice for a relay's lifetime, so + // keying the cache on it is defensive; the rc scan behind it is memoized. + const sourceDir = resolveOpenCodeSourceConfigDir(env as Record, env.SHELL) + const cached = materialized + if ( + cached && + (incoming === null || incoming === cached.source) && + sourceDir === cached.sourceDir && + // Why: the dir surviving a failed rebuild proves nothing — the plugin does. + existsSync(getRelayOpenCodePluginPath(cached.dir)) + ) { + opencodeDir = cached.dir + } else { + const overlayId = + sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode' + // Why: null on write failure — caller falls back to the guest's own config (no status), never crossing a Windows overlay into WSL. + opencodeDir = pluginOverlay.materializeOpenCode(overlayId, sourceDir) ?? undefined + materialized = + opencodeDir && incoming !== null + ? { source: incoming, sourceDir, dir: opencodeDir } + : null + } + } + return { + installed: { + opencode: pluginOverlay.hasOpenCodeSource(), + pi: pluginOverlay.hasPiSource('pi'), + omp: pluginOverlay.hasPiSource('omp') + }, + overlayDirs: opencodeDir ? { opencode: opencodeDir } : {} + } + } +} diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index c8999e17e11..4920a510dc4 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -75,6 +75,12 @@ import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initia import { removeDiffSectionMeasuredHeight } from './diff-section-height-cache' import { createCombinedDiffLoadScheduler } from './combined-diff-load-scheduler' import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match' +import { + COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES, + getCombinedDiffViewedSectionKeys, + retainCombinedDiffSectionText, + retainCombinedDiffViewStateText +} from './combined-diff-text-retention' import { beginCombinedDiffScrollbarDrag, type CombinedDiffScrollbarDragCleanup @@ -294,9 +300,28 @@ export default function CombinedDiffViewer({ const loadedIndicesRef = useRef>(new Set()) const loadingIndicesRef = useRef>(new Set()) const sectionsRef = useRef([]) + const protectedSectionKeysRef = useRef>(new Set()) const generationRef = useRef(0) const loadSectionRef = useRef<(index: number) => Promise>(async () => {}) const retrySectionRef = useRef<(index: number) => void>(() => {}) + const applySectionTextRetention = useCallback( + (nextSections: DiffSection[], additionallyProtectedKey?: string): DiffSection[] => { + const protectedSectionKeys = new Set(protectedSectionKeysRef.current) + if (additionallyProtectedKey) { + protectedSectionKeys.add(additionallyProtectedKey) + } + const retained = retainCombinedDiffSectionText({ + sections: nextSections, + loadedIndices: loadedIndicesRef.current, + protectedSectionKeys + }) + for (const index of retained.evictedIndices) { + loadedIndicesRef.current.delete(index) + } + return retained.sections + }, + [] + ) const updateCombinedDiffScrollbar = useCallback(() => { const container = scrollContainerRef.current if (!container || container.scrollHeight <= container.clientHeight + 1) { @@ -689,7 +714,7 @@ export default function CombinedDiffViewer({ const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) loadedIndicesRef.current.add(index) setSections((prev) => { - return prev.map((s, i) => + const nextSections = prev.map((s, i) => i === index ? { ...s, @@ -702,6 +727,7 @@ export default function CombinedDiffViewer({ } : s ) + return applySectionTextRetention(nextSections, nextSections[index]?.key) }) }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -719,7 +745,8 @@ export default function CombinedDiffViewer({ isBranchMode, isCommitMode, renderableBranchEntries, - uncommittedEntries + uncommittedEntries, + applySectionTextRetention ] ) loadSectionRef.current = loadSectionNow @@ -987,8 +1014,20 @@ export default function CombinedDiffViewer({ // Why: the tree highlight belongs to one entry set; reset now so it can't flash on another before an Effect would. setActiveTreeSectionState({ entrySignature, key: null }) } + const protectedSectionKeys = [ + ...virtualizer + .getVirtualItems() + .map((item) => sections[item.index]?.key) + .filter((key): key is string => key !== undefined), + ...(activeTreeSectionKey ? [activeTreeSectionKey] : []) + ] + protectedSectionKeysRef.current = new Set(protectedSectionKeys) + const protectedSectionSignature = protectedSectionKeys.join('\0') + useLayoutEffect(() => { + setSections((current) => applySectionTextRetention(current)) + }, [applySectionTextRetention, protectedSectionSignature, sections]) const viewedSectionKeys = React.useMemo( - () => new Set(sections.filter((section) => !section.loading).map((section) => section.key)), + () => getCombinedDiffViewedSectionKeys(sections), [sections] ) const handleTreeNavigate = useCallback( @@ -1203,8 +1242,8 @@ export default function CombinedDiffViewer({ content ) setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => - prev.map((s, i) => { + setSections((prev) => { + const nextSections = prev.map((s, i) => { if (i !== index) { return s } @@ -1234,12 +1273,20 @@ export default function CombinedDiffViewer({ largeDiffRenderLimit: nextLargeDiffRenderLimit } }) - ) + return applySectionTextRetention(nextSections, nextSections[index]?.key) + }) } catch (err) { console.error('Save failed:', err) } }, - [file.filePath, file.operationProvenance, file.runtimeEnvironmentId, file.worktreeId, sections] + [ + applySectionTextRetention, + file.filePath, + file.operationProvenance, + file.runtimeEnvironmentId, + file.worktreeId, + sections + ] ) const handleSectionSaveRef = useRef(handleSectionSave) @@ -1251,17 +1298,23 @@ export default function CombinedDiffViewer({ } const preservedScrollTop = combinedDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0 - setWithLRU(combinedDiffViewStateCache, viewStateKey, { - entrySignature, - gitStatusSignature: combinedGitStatusSignature, - sections, - sectionHeights, - loadedIndices: Array.from(loadedIndicesRef.current).filter( - (index) => !sections[index]?.loading - ), - scrollTop: preservedScrollTop, - sideBySide - }) + setWithLRU( + combinedDiffViewStateCache, + viewStateKey, + { + entrySignature, + gitStatusSignature: combinedGitStatusSignature, + sections, + sectionHeights, + loadedIndices: Array.from(loadedIndicesRef.current).filter( + (index) => !sections[index]?.loading + ), + scrollTop: preservedScrollTop, + sideBySide + }, + COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES + ) + retainCombinedDiffViewStateText(combinedDiffViewStateCache) }, [ combinedGitStatusSignature, entries.length, diff --git a/src/renderer/src/components/editor/CsvViewer.tsx b/src/renderer/src/components/editor/CsvViewer.tsx index e424a98c938..d042fd1e31e 100644 --- a/src/renderer/src/components/editor/CsvViewer.tsx +++ b/src/renderer/src/components/editor/CsvViewer.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useRef } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import { detectCsvDelimiter, parseCsv } from './csv-parse' +import { CSV_PARSE_LIMITS, detectCsvDelimiter, parseCsv } from './csv-parse' import { translate } from '@/i18n/i18n' type CsvViewerProps = { @@ -14,6 +14,40 @@ const MIN_COL_PX = 80 const MAX_COL_PX = 320 const ROW_NUMBER_COL_PX = 48 const CHAR_PX = 7 +const numberFormatter = new Intl.NumberFormat() + +function CsvLimitFallback({ filePath }: { filePath: string }): React.JSX.Element { + return ( +
+
+
+ {translate( + 'auto.components.editor.CsvViewer.2b65aa2913', + 'This CSV is too large to display safely.' + )} +
+
{filePath}
+
+ {translate( + 'auto.components.editor.CsvViewer.d5e8a2b453', + 'Table view is limited to {{rows}} rows, {{columns}} columns, and {{cells}} cells.', + { + rows: numberFormatter.format(CSV_PARSE_LIMITS.rows), + columns: numberFormatter.format(CSV_PARSE_LIMITS.columnsPerRow), + cells: numberFormatter.format(CSV_PARSE_LIMITS.cells) + } + )} +
+
+ {translate( + 'auto.components.editor.CsvViewer.59cfb175a2', + 'Switch to source mode to inspect the raw text.' + )} +
+
+
+ ) +} // Why: CsvViewer is the table counterpart to source-mode Monaco for .csv/.tsv // files. Row virtualization via @tanstack/react-virtual keeps large files @@ -89,6 +123,10 @@ export default function CsvViewer({ content, filePath }: CsvViewerProps): React. getItemKey: (index) => index }) + if (parsed.limitExceeded) { + return + } + if (parsed.rows.length === 0) { return (
diff --git a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx index 0e7d9f6262a..d0cc1cc78d9 100644 --- a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx @@ -7,7 +7,12 @@ const lifecycle = vi.hoisted(() => ({ events: [] as string[], diffModelKeys: [] as string[], models: new Map(), - mountedProps: [] as { filePath: string; readOnly?: boolean; liveTail?: boolean }[] + mountedProps: [] as { + filePath: string + readOnly?: boolean + liveTail?: boolean + viewStateId?: string + }[] })) vi.mock('@/lib/lazy-with-retry', async () => { @@ -37,6 +42,7 @@ vi.mock('@/lib/lazy-with-retry', async () => { content: string readOnly?: boolean liveTail?: boolean + viewStateId?: string }) { /* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */ React.useEffect(() => { @@ -44,7 +50,8 @@ vi.mock('@/lib/lazy-with-retry', async () => { lifecycle.mountedProps.push({ filePath: props.filePath, readOnly: props.readOnly, - liveTail: props.liveTail + liveTail: props.liveTail, + viewStateId: props.viewStateId }) const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] } const model = { @@ -241,7 +248,17 @@ describe('EditorContent Monaco lifecycle boundary', () => { render() expect(lifecycle.mountedProps).toEqual([ - { filePath: liveLog.filePath, readOnly: true, liveTail: true } + { filePath: liveLog.filePath, readOnly: true, liveTail: true, viewStateId: 'same-pane' } ]) }) + + it('tells Monaco which pane it is so it can retire an explicit focus handoff', () => { + const source = file('/repo/main.ts') + + render() + + // Why: without the pane id Monaco cannot match the handoff, so it silently leaves the request + // armed and a later rich-mode remount of this pane steals focus back. + expect(lifecycle.mountedProps.at(0)?.viewStateId).toBe('same-pane') + }) }) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 7079770ffd9..eb8afa2d5c9 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -307,6 +307,7 @@ export function EditorContent({ fileId={activeFile.id} filePath={activeFile.filePath} viewStateKey={editorViewStateKey} + viewStateId={viewStateScopeId} relativePath={activeFile.relativePath} content={editBuffers[activeFile.id] ?? fc.content} language={monacoLanguage} @@ -386,6 +387,7 @@ export function EditorContent({ { + it('decodes large and whitespace-delimited input without changing bytes', () => { + const expected = new Uint8Array(40_000).map((_, index) => index % 251) + const encoded = btoa(String.fromCharCode(...expected)).replace(/.{100}/g, '$&\n') + + expect(decodeBase64Bytes(encoded)).toEqual(expected) + }) +}) diff --git a/src/renderer/src/components/editor/base64-byte-decoder.ts b/src/renderer/src/components/editor/base64-byte-decoder.ts new file mode 100644 index 00000000000..364c33e327e --- /dev/null +++ b/src/renderer/src/components/editor/base64-byte-decoder.ts @@ -0,0 +1,18 @@ +const BASE64_DECODE_CHUNK_CHARS = 32 * 1024 + +export function decodeBase64Bytes(base64: string): Uint8Array { + const normalized = /\s/.test(base64) ? base64.replace(/\s/g, '') : base64 + const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0 + const bytes = new Uint8Array(Math.floor((normalized.length * 3) / 4) - padding) + let targetOffset = 0 + for (let sourceOffset = 0; sourceOffset < normalized.length; ) { + const sourceEnd = Math.min(sourceOffset + BASE64_DECODE_CHUNK_CHARS, normalized.length) + const binary = atob(normalized.slice(sourceOffset, sourceEnd)) + for (let index = 0; index < binary.length; index += 1) { + bytes[targetOffset] = binary.charCodeAt(index) + targetOffset += 1 + } + sourceOffset = sourceEnd + } + return bytes +} diff --git a/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts b/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts index a982d6b692c..302a76cbdb6 100644 --- a/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts +++ b/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts @@ -183,6 +183,70 @@ describe('combined diff load scheduler', () => { expect(started).toEqual([1, 3]) }) + it('counts stale in-flight loads against concurrency across repeated resets', async () => { + const blockers = new Map([1, 2, 3, 5].map((index) => [index, deferred()])) + const started: number[] = [] + let active = 0 + let maxActive = 0 + const scheduler = createCombinedDiffLoadScheduler({ + maxConcurrent: 2, + schedule: (callback) => callback(), + loadSection: async (index) => { + started.push(index) + active += 1 + maxActive = Math.max(maxActive, active) + await blockers.get(index)!.promise + active -= 1 + } + }) + + scheduler.request(1) + scheduler.request(2) + scheduler.reset() + scheduler.request(3) + scheduler.request(4) + expect(started).toEqual([1, 2]) + + blockers.get(1)!.resolve() + await flushMicrotasks() + expect(started).toEqual([1, 2, 3]) + + scheduler.reset() + scheduler.request(5) + blockers.get(2)!.resolve() + await flushMicrotasks() + expect(started).toEqual([1, 2, 3, 5]) + expect(maxActive).toBe(2) + + blockers.get(3)!.resolve() + blockers.get(5)!.resolve() + await flushMicrotasks() + }) + + it('keeps a rerequest deduped while the previous load settles', async () => { + const first = deferred() + const second = deferred() + const started: number[] = [] + const scheduler = createCombinedDiffLoadScheduler({ + schedule: (callback) => callback(), + loadSection: async (index) => { + started.push(index) + await (started.length === 1 ? first.promise : second.promise) + } + }) + + scheduler.request(4) + scheduler.rerequest(4) + scheduler.request(4) + first.resolve() + await flushMicrotasks() + scheduler.request(4) + + expect(started).toEqual([4, 4]) + second.resolve() + await flushMicrotasks() + }) + it('revives after dispose when reset for a StrictMode remount', async () => { const started: number[] = [] const scheduler = createCombinedDiffLoadScheduler({ diff --git a/src/renderer/src/components/editor/combined-diff-load-scheduler.ts b/src/renderer/src/components/editor/combined-diff-load-scheduler.ts index 61c480dd716..45a344b7c6b 100644 --- a/src/renderer/src/components/editor/combined-diff-load-scheduler.ts +++ b/src/renderer/src/components/editor/combined-diff-load-scheduler.ts @@ -5,6 +5,11 @@ export type CombinedDiffLoadScheduler = { dispose: () => void } +type PendingLoad = { + index: number + requestId: number +} + export function createCombinedDiffLoadScheduler({ loadSection, schedule = (callback) => queueMicrotask(callback), @@ -16,11 +21,12 @@ export function createCombinedDiffLoadScheduler({ schedule?: (callback: () => void) => void maxConcurrent?: number }): CombinedDiffLoadScheduler { - const pending: number[] = [] - const queued = new Set() + const pending: PendingLoad[] = [] + const queuedRequestByIndex = new Map() let active = 0 let disposed = false let version = 0 + let nextRequestId = 0 const drain = (drainVersion: number): void => { if (disposed || drainVersion !== version) { @@ -28,29 +34,33 @@ export function createCombinedDiffLoadScheduler({ } while (active < maxConcurrent) { - const nextIndex = pending.shift() - if (nextIndex === undefined) { + const next = pending.shift() + if (!next) { return } active += 1 - void loadSection(nextIndex).finally(() => { - queued.delete(nextIndex) - if (disposed || drainVersion !== version) { + void loadSection(next.index).finally(() => { + active = Math.max(0, active - 1) + if (queuedRequestByIndex.get(next.index) === next.requestId) { + queuedRequestByIndex.delete(next.index) + } + if (disposed) { return } - active = Math.max(0, active - 1) - schedule(() => drain(drainVersion)) + const currentVersion = version + schedule(() => drain(currentVersion)) }) } } const enqueue = (index: number): void => { - if (disposed || queued.has(index)) { + if (disposed || queuedRequestByIndex.has(index)) { return } - queued.add(index) - pending.push(index) + const requestId = ++nextRequestId + queuedRequestByIndex.set(index, requestId) + pending.push({ index, requestId }) const requestVersion = version schedule(() => drain(requestVersion)) } @@ -63,8 +73,9 @@ export function createCombinedDiffLoadScheduler({ if (disposed) { return } - queued.delete(index) - const pendingIndex = pending.indexOf(index) + const requestId = queuedRequestByIndex.get(index) + queuedRequestByIndex.delete(index) + const pendingIndex = pending.findIndex((load) => load.requestId === requestId) if (pendingIndex !== -1) { pending.splice(pendingIndex, 1) } @@ -73,14 +84,14 @@ export function createCombinedDiffLoadScheduler({ reset() { disposed = false version += 1 + // Why: don't reset `active`; stale loads still consume memory and I/O until they settle. pending.length = 0 - queued.clear() - active = 0 + queuedRequestByIndex.clear() }, dispose() { disposed = true pending.length = 0 - queued.clear() + queuedRequestByIndex.clear() } } } diff --git a/src/renderer/src/components/editor/combined-diff-text-retention.test.ts b/src/renderer/src/components/editor/combined-diff-text-retention.test.ts new file mode 100644 index 00000000000..99243dc238f --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff-text-retention.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../../../shared/large-diff-render-limit' +import type { DiffSection } from './diff-section-types' +import { + COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES, + MAX_RETAINED_COMBINED_DIFF_TEXT_BYTES, + MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS, + getCombinedDiffViewedSectionKeys, + inspectCombinedDiffTextRetention, + retainCombinedDiffSectionText, + retainCombinedDiffViewStateText +} from './combined-diff-text-retention' + +function textSection(key: string, content: string): DiffSection { + return { + key, + path: `${key}.ts`, + status: 'modified', + originalContent: content, + modifiedContent: '', + collapsed: false, + loading: false, + dirty: false, + diffResult: { + kind: 'text', + originalContent: content, + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + }, + largeDiffRenderLimit: null + } +} + +describe('combined diff text retention', () => { + it('bounds many individually renderable files by evicting the oldest offscreen bodies', () => { + const justUnderPerFileLimit = 'x'.repeat(MAX_RENDERED_DIFF_COMBINED_CHARACTERS - 1) + const sections = Array.from({ length: 10 }, (_, index) => + textSection(`section-${index}`, justUnderPerFileLimit) + ) + const loadedIndices = sections.map((_, index) => index) + + const result = retainCombinedDiffSectionText({ + sections, + loadedIndices, + protectedSectionKeys: new Set(['section-9']) + }) + + const inspection = inspectCombinedDiffTextRetention(result.sections) + expect(inspection.characters).toBeLessThanOrEqual(MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS) + expect(inspection.approximateBytes).toBeLessThanOrEqual(MAX_RETAINED_COMBINED_DIFF_TEXT_BYTES) + expect(result.evictedIndices[0]).toBe(0) + expect(result.sections[9].originalContent).toBe(justUnderPerFileLimit) + expect(result.loadedIndices).not.toContain(result.evictedIndices[0]) + for (const index of result.evictedIndices) { + expect(result.sections[index]).toMatchObject({ + originalContent: '', + modifiedContent: '', + loading: true, + diffResult: null + }) + } + }) + + it('makes an evicted section reloadable and retains its refetched body when revisited', () => { + const firstPass = retainCombinedDiffSectionText({ + sections: [textSection('old', '12345678'), textSection('active', 'abcdefgh')], + loadedIndices: [0, 1], + protectedSectionKeys: new Set(['active']), + maxCharacters: 10 + }) + + expect(firstPass.evictedIndices).toEqual([0]) + expect(firstPass.loadedIndices).toEqual([1]) + expect(getCombinedDiffViewedSectionKeys(firstPass.sections)).toEqual(new Set(['old', 'active'])) + + const refetched = firstPass.sections.map((section, index) => + index === 0 ? textSection('old', 'refetched') : section + ) + const secondPass = retainCombinedDiffSectionText({ + sections: refetched, + loadedIndices: [...firstPass.loadedIndices, 0], + protectedSectionKeys: new Set(['old']), + maxCharacters: 10 + }) + + expect(secondPass.sections[0].originalContent).toBe('refetched') + expect(secondPass.loadedIndices).toContain(0) + expect(secondPass.evictedIndices).toEqual([1]) + }) + + it('keeps the full view-state LRU within one aggregate text budget', () => { + const body = 'x'.repeat(Math.floor(MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS / 2)) + const viewStates = new Map( + Array.from({ length: COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES }, (_, index) => [ + `view-${index}`, + { sections: [textSection(`section-${index}`, body)], loadedIndices: [0] } + ]) + ) + + const inspection = retainCombinedDiffViewStateText(viewStates) + + expect(inspection.characters).toBeLessThanOrEqual(MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS) + expect(viewStates.get('view-0')?.sections[0].originalContent).toBe('') + expect(viewStates.get('view-19')?.sections[0].originalContent).toBe(body) + }) + + it('never evicts active, loading, or unsaved section text', () => { + const dirty = { ...textSection('dirty', 'dirty text'), dirty: true } + const loading = { ...textSection('loading', 'loading text'), loading: true } + const result = retainCombinedDiffSectionText({ + sections: [ + textSection('old', 'old text'), + dirty, + loading, + textSection('active', 'active text') + ], + loadedIndices: [0, 1, 2, 3], + protectedSectionKeys: new Set(['active']), + maxCharacters: 0 + }) + + expect(result.evictedIndices).toEqual([0]) + expect(result.sections[1].originalContent).toBe('dirty text') + expect(result.sections[2].originalContent).toBe('loading text') + expect(result.sections[3].originalContent).toBe('active text') + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff-text-retention.ts b/src/renderer/src/components/editor/combined-diff-text-retention.ts new file mode 100644 index 00000000000..137eec85c2c --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff-text-retention.ts @@ -0,0 +1,205 @@ +import type { DiffSection } from './diff-section-types' + +export const COMBINED_DIFF_TEXT_BYTES_PER_CHARACTER = 2 +// Why: bound raw UTF-16 text before Monaco creates its own models; inactive +// snapshots split the same budget and may refetch an oversized file on revisit. +export const MAX_RETAINED_COMBINED_DIFF_TEXT_BYTES = 24 * 1024 * 1024 +export const MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS = Math.floor( + MAX_RETAINED_COMBINED_DIFF_TEXT_BYTES / COMBINED_DIFF_TEXT_BYTES_PER_CHARACTER +) +export const COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES = 20 + +export type CombinedDiffTextRetentionInspection = { + characters: number + approximateBytes: number + sectionsWithText: number +} + +export type CombinedDiffTextRetentionResult = CombinedDiffTextRetentionInspection & { + sections: DiffSection[] + loadedIndices: number[] + evictedIndices: number[] +} + +type CombinedDiffTextViewState = { + sections: DiffSection[] + loadedIndices: number[] +} + +function getSectionTextCharacters(section: DiffSection): number { + if (section.diffResult?.kind !== 'text') { + return 0 + } + return section.originalContent.length + section.modifiedContent.length +} + +export function inspectCombinedDiffTextRetention( + sections: readonly DiffSection[] +): CombinedDiffTextRetentionInspection { + let characters = 0 + let sectionsWithText = 0 + for (const section of sections) { + const sectionCharacters = getSectionTextCharacters(section) + if (sectionCharacters === 0) { + continue + } + characters += sectionCharacters + sectionsWithText += 1 + } + return { + characters, + approximateBytes: characters * COMBINED_DIFF_TEXT_BYTES_PER_CHARACTER, + sectionsWithText + } +} + +function isReloadableSection( + section: DiffSection, + protectedSectionKeys: ReadonlySet +): boolean { + return ( + getSectionTextCharacters(section) > 0 && + !section.loading && + !section.dirty && + !section.error && + !protectedSectionKeys.has(section.key) + ) +} + +function releaseSectionText(section: DiffSection): DiffSection { + return { + ...section, + originalContent: '', + modifiedContent: '', + diffResult: null, + loading: true, + contentGeneration: (section.contentGeneration ?? 0) + 1, + textEvictedForRetention: true + } +} + +export function getCombinedDiffViewedSectionKeys( + sections: readonly DiffSection[] +): ReadonlySet { + return new Set( + sections + .filter((section) => !section.loading || section.textEvictedForRetention === true) + .map((section) => section.key) + ) +} + +export function retainCombinedDiffSectionText({ + sections, + loadedIndices, + protectedSectionKeys = new Set(), + maxCharacters = MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS +}: { + sections: DiffSection[] + loadedIndices: Iterable + protectedSectionKeys?: ReadonlySet + maxCharacters?: number +}): CombinedDiffTextRetentionResult { + const inspection = inspectCombinedDiffTextRetention(sections) + const loadedIndexOrder = Array.from(loadedIndices) + if (inspection.characters <= maxCharacters) { + return { + ...inspection, + sections, + loadedIndices: loadedIndexOrder, + evictedIndices: [] + } + } + + // Why: loaded indices preserve load order; a refetched section rejoins at the tail. + const orderedCandidates = loadedIndexOrder.filter((index) => { + const section = sections[index] + return section !== undefined && isReloadableSection(section, protectedSectionKeys) + }) + const orderedCandidateSet = new Set(orderedCandidates) + for (let index = 0; index < sections.length; index += 1) { + const section = sections[index] + if ( + !orderedCandidateSet.has(index) && + section !== undefined && + isReloadableSection(section, protectedSectionKeys) + ) { + orderedCandidates.push(index) + } + } + + let retainedCharacters = inspection.characters + let retainedSections = sections + const evictedIndices: number[] = [] + for (const index of orderedCandidates) { + if (retainedCharacters <= maxCharacters) { + break + } + const section = retainedSections[index] + const sectionCharacters = getSectionTextCharacters(section) + if (sectionCharacters === 0) { + continue + } + if (retainedSections === sections) { + retainedSections = [...sections] + } + retainedSections[index] = releaseSectionText(section) + retainedCharacters -= sectionCharacters + evictedIndices.push(index) + } + + const evictedIndexSet = new Set(evictedIndices) + return { + characters: retainedCharacters, + approximateBytes: retainedCharacters * COMBINED_DIFF_TEXT_BYTES_PER_CHARACTER, + sectionsWithText: inspection.sectionsWithText - evictedIndices.length, + sections: retainedSections, + loadedIndices: loadedIndexOrder.filter((index) => !evictedIndexSet.has(index)), + evictedIndices + } +} + +export function inspectCombinedDiffViewStateTextRetention( + viewStates: Iterable +): CombinedDiffTextRetentionInspection { + let characters = 0 + let sectionsWithText = 0 + for (const viewState of viewStates) { + const inspection = inspectCombinedDiffTextRetention(viewState.sections) + characters += inspection.characters + sectionsWithText += inspection.sectionsWithText + } + return { + characters, + approximateBytes: characters * COMBINED_DIFF_TEXT_BYTES_PER_CHARACTER, + sectionsWithText + } +} + +export function retainCombinedDiffViewStateText( + viewStates: Map, + maxCharacters = MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS +): CombinedDiffTextRetentionInspection { + let retainedCharacters = inspectCombinedDiffViewStateTextRetention(viewStates.values()).characters + // Why: view-state Maps are LRUs, so iteration releases the oldest inactive view first. + for (const [key, viewState] of viewStates) { + if (retainedCharacters <= maxCharacters) { + break + } + const viewStateCharacters = inspectCombinedDiffTextRetention(viewState.sections).characters + const retained = retainCombinedDiffSectionText({ + sections: viewState.sections, + loadedIndices: viewState.loadedIndices, + maxCharacters: Math.max(0, viewStateCharacters - (retainedCharacters - maxCharacters)) + }) + if (retained.evictedIndices.length === 0) { + continue + } + viewStates.set(key, { + ...viewState, + sections: retained.sections, + loadedIndices: retained.loadedIndices + }) + retainedCharacters -= viewStateCharacters - retained.characters + } + return inspectCombinedDiffViewStateTextRetention(viewStates.values()) +} diff --git a/src/renderer/src/components/editor/csv-parse.test.ts b/src/renderer/src/components/editor/csv-parse.test.ts index 267be8b1028..34581559c61 100644 --- a/src/renderer/src/components/editor/csv-parse.test.ts +++ b/src/renderer/src/components/editor/csv-parse.test.ts @@ -1,5 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS, detectCsvDelimiter, parseCsv } from './csv-parse' +import { + CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS, + CSV_PARSE_LIMITS, + type CsvParseLimits, + detectCsvDelimiter, + parseCsv +} from './csv-parse' + +function parseLimits(overrides: Partial): CsvParseLimits { + return { ...CSV_PARSE_LIMITS, ...overrides } +} afterEach(() => { vi.restoreAllMocks() @@ -68,8 +78,82 @@ describe('parseCsv', () => { const { rows } = parseCsv('""') expect(rows).toEqual([['']]) }) + + it.each([ + { + name: 'source code units', + source: 'a,b\n', + limits: parseLimits({ sourceCodeUnits: 3 }), + reason: 'source-code-units' + }, + { + name: 'rows', + source: 'a\nb\nc', + limits: parseLimits({ rows: 2 }), + reason: 'rows' + }, + { + name: 'columns per row', + source: 'a,b,c', + limits: parseLimits({ columnsPerRow: 2 }), + reason: 'columns-per-row' + }, + { + name: 'total cells', + source: 'a,b\nc,d,e', + limits: parseLimits({ cells: 4 }), + reason: 'cells' + }, + { + name: 'retained cell text', + source: 'a,bcd', + limits: parseLimits({ retainedCellCodeUnits: 3 }), + reason: 'retained-cell-code-units' + } + ])('fails closed when $name exceeds its memory limit', ({ source, limits, reason }) => { + expect(parseCsv(source, ',', limits)).toEqual({ + rows: [], + maxColumns: 0, + limitExceeded: { reason, limit: limits[reasonToLimitKey(reason)] } + }) + }) + + it('preserves exact output at every parser boundary', () => { + const source = 'a,b\nc,d' + const limits = parseLimits({ + sourceCodeUnits: source.length, + rows: 2, + columnsPerRow: 2, + cells: 4, + retainedCellCodeUnits: 4 + }) + + expect(parseCsv(source, ',', limits)).toEqual({ + rows: [ + ['a', 'b'], + ['c', 'd'] + ], + maxColumns: 2, + limitExceeded: null + }) + }) }) +function reasonToLimitKey(reason: string): keyof CsvParseLimits { + switch (reason) { + case 'source-code-units': + return 'sourceCodeUnits' + case 'rows': + return 'rows' + case 'columns-per-row': + return 'columnsPerRow' + case 'cells': + return 'cells' + default: + return 'retainedCellCodeUnits' + } +} + describe('detectCsvDelimiter', () => { it('uses tab for .tsv files regardless of content', () => { expect(detectCsvDelimiter('data.tsv', 'a,b,c')).toBe('\t') diff --git a/src/renderer/src/components/editor/csv-parse.ts b/src/renderer/src/components/editor/csv-parse.ts index 009e9646320..095b76779f7 100644 --- a/src/renderer/src/components/editor/csv-parse.ts +++ b/src/renderer/src/components/editor/csv-parse.ts @@ -1,9 +1,37 @@ export type CsvParseResult = { rows: string[][] maxColumns: number + limitExceeded: CsvParseLimitExceeded | null } export const CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS = 64 * 1024 +export const CSV_PARSE_LIMITS = { + sourceCodeUnits: 50 * 1024 * 1024, + rows: 250_000, + columnsPerRow: 1024, + cells: 1_000_000, + retainedCellCodeUnits: 50 * 1024 * 1024 +} as const + +export type CsvParseLimitReason = + | 'source-code-units' + | 'rows' + | 'columns-per-row' + | 'cells' + | 'retained-cell-code-units' + +export type CsvParseLimitExceeded = { + reason: CsvParseLimitReason + limit: number +} + +export type CsvParseLimits = Readonly<{ + sourceCodeUnits: number + rows: number + columnsPerRow: number + cells: number + retainedCellCodeUnits: number +}> const LINE_FEED_CODE_UNIT = 10 const CARRIAGE_RETURN_CODE_UNIT = 13 @@ -12,7 +40,21 @@ const CARRIAGE_RETURN_CODE_UNIT = 13 // A hand-rolled parser avoids pulling a new dependency (papaparse) for what is // a small, well-specified grammar. Inline state machine keeps the hot path // allocation-light for large files. -export function parseCsv(source: string, delimiter: string = ','): CsvParseResult { +export function parseCsv( + source: string, + delimiter: string = ',', + limits: CsvParseLimits = CSV_PARSE_LIMITS +): CsvParseResult { + const limited = (reason: CsvParseLimitReason, limit: number): CsvParseResult => ({ + rows: [], + maxColumns: 0, + limitExceeded: { reason, limit } + }) + + if (source.length > limits.sourceCodeUnits) { + return limited('source-code-units', limits.sourceCodeUnits) + } + // Why: strip a leading UTF-8 BOM (U+FEFF). Excel and other spreadsheet tools // prepend a BOM to exported CSVs; without this, the BOM contaminates the // first header cell and breaks column-name lookups for downstream consumers. @@ -25,6 +67,8 @@ export function parseCsv(source: string, delimiter: string = ','): CsvParseResul let field = '' let inQuotes = false let maxColumns = 0 + let cellCount = 0 + let retainedCellCodeUnits = 0 // Why: track whether the current record has produced any content — including // a quoted-but-empty field like `""`. Without this flag, the EOF flush // condition `field.length > 0 || row.length > 0` would drop a record whose @@ -32,18 +76,41 @@ export function parseCsv(source: string, delimiter: string = ','): CsvParseResul // delimiter/newline ever pushed it onto the row. let recordHasContent = false - const pushField = (): void => { + const pushField = (): CsvParseResult | null => { + if (row.length >= limits.columnsPerRow) { + return limited('columns-per-row', limits.columnsPerRow) + } + if (cellCount >= limits.cells) { + return limited('cells', limits.cells) + } row.push(field) + cellCount += 1 field = '' + return null } - const pushRow = (): void => { - pushField() + const pushRow = (): CsvParseResult | null => { + if (rows.length >= limits.rows) { + return limited('rows', limits.rows) + } + const fieldLimit = pushField() + if (fieldLimit) { + return fieldLimit + } if (row.length > maxColumns) { maxColumns = row.length } rows.push(row) row = [] recordHasContent = false + return null + } + const appendToField = (value: string): CsvParseResult | null => { + if (retainedCellCodeUnits + value.length > limits.retainedCellCodeUnits) { + return limited('retained-cell-code-units', limits.retainedCellCodeUnits) + } + field += value + retainedCellCodeUnits += value.length + return null } for (let i = 0; i < source.length; i += 1) { @@ -52,13 +119,19 @@ export function parseCsv(source: string, delimiter: string = ','): CsvParseResul if (inQuotes) { if (ch === '"') { if (source[i + 1] === '"') { - field += '"' + const retainedLimit = appendToField('"') + if (retainedLimit) { + return retainedLimit + } i += 1 } else { inQuotes = false } } else { - field += ch + const retainedLimit = appendToField(ch) + if (retainedLimit) { + return retainedLimit + } } continue } @@ -71,7 +144,10 @@ export function parseCsv(source: string, delimiter: string = ','): CsvParseResul continue } if (ch === delimiter) { - pushField() + const fieldLimit = pushField() + if (fieldLimit) { + return fieldLimit + } recordHasContent = true continue } @@ -79,14 +155,23 @@ export function parseCsv(source: string, delimiter: string = ','): CsvParseResul if (source[i + 1] === '\n') { i += 1 } - pushRow() + const rowLimit = pushRow() + if (rowLimit) { + return rowLimit + } continue } if (ch === '\n') { - pushRow() + const rowLimit = pushRow() + if (rowLimit) { + return rowLimit + } continue } - field += ch + const retainedLimit = appendToField(ch) + if (retainedLimit) { + return retainedLimit + } recordHasContent = true } @@ -97,10 +182,13 @@ export function parseCsv(source: string, delimiter: string = ','): CsvParseResul // empty and nothing has been pushed to `row`, but the record is real and // must not be dropped. if (field.length > 0 || row.length > 0 || recordHasContent) { - pushRow() + const rowLimit = pushRow() + if (rowLimit) { + return rowLimit + } } - return { rows, maxColumns } + return { rows, maxColumns, limitExceeded: null } } export function detectCsvDelimiter(filePath: string, content: string): string { diff --git a/src/renderer/src/components/editor/diff-section-types.ts b/src/renderer/src/components/editor/diff-section-types.ts index 0c5d659f4be..0d23aafe0a5 100644 --- a/src/renderer/src/components/editor/diff-section-types.ts +++ b/src/renderer/src/components/editor/diff-section-types.ts @@ -20,4 +20,6 @@ export type DiffSection = { // Why: combined sections keep Monaco models by path; bump on reload so // refetched git content does not replay through keepCurrent* model reuse. contentGeneration?: number + // Why: releasing a reviewed body must not make its file-tree entry appear unviewed. + textEvictedForRetention?: boolean } diff --git a/src/renderer/src/components/editor/ipynb-json-admission.test.ts b/src/renderer/src/components/editor/ipynb-json-admission.test.ts new file mode 100644 index 00000000000..354cf2002f2 --- /dev/null +++ b/src/renderer/src/components/editor/ipynb-json-admission.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + assertIpynbJsonWithinMemoryLimits, + assertIpynbShapeWithinMemoryLimits, + IPYNB_MEMORY_LIMITS, + type IpynbMemoryLimits +} from './ipynb-json-admission' + +function limits(overrides: Partial): IpynbMemoryLimits { + return { ...IPYNB_MEMORY_LIMITS, ...overrides } +} + +describe('notebook memory admission', () => { + it('preserves JSON at exact source, structure, and nesting boundaries', () => { + const content = '{"cells":[]}' + expect(() => + assertIpynbJsonWithinMemoryLimits( + content, + limits({ sourceCodeUnits: content.length, structuralTokens: 5, nestingDepth: 2 }) + ) + ).not.toThrow() + }) + + it.each([ + ['source size', '{"cells":[]}', { sourceCodeUnits: 11 }, 'source size'], + ['JSON structure', '{"cells":[]}', { structuralTokens: 4 }, 'JSON structure'], + ['JSON nesting', '{"cells":[]}', { nestingDepth: 1 }, 'JSON nesting'] + ] as const)('rejects %s one step past its limit', (_name, content, override, message) => { + expect(() => assertIpynbJsonWithinMemoryLimits(content, limits(override))).toThrow(message) + }) + + it('ignores structural characters inside strings', () => { + const content = '{"cells":[{"source":"[{,:}]"}]}' + expect(() => + assertIpynbJsonWithinMemoryLimits(content, limits({ structuralTokens: 9 })) + ).not.toThrow() + }) + + it.each([ + ['cells', [{}, {}], { cells: 1 }, 'cell'], + ['outputs', [{ outputs: [{}, {}] }], { outputs: 1 }, 'output'], + [ + 'display items', + [{ outputs: [{ data: { one: 1, two: 2 } }] }], + { displayItems: 1 }, + 'display item' + ], + ['multiline parts', [{ source: ['one', 'two'] }], { multilineParts: 1 }, 'fragment'] + ] as const)('rejects too many %s before render projection', (_name, cells, override, message) => { + expect(() => assertIpynbShapeWithinMemoryLimits(cells, limits(override))).toThrow(message) + }) +}) diff --git a/src/renderer/src/components/editor/ipynb-json-admission.ts b/src/renderer/src/components/editor/ipynb-json-admission.ts new file mode 100644 index 00000000000..8b1cf6f5ca1 --- /dev/null +++ b/src/renderer/src/components/editor/ipynb-json-admission.ts @@ -0,0 +1,94 @@ +import { assertJsonTextStructureWithinLimits } from '../../../../shared/memory-safety/json-text-structure-limit' + +export const IPYNB_MEMORY_LIMITS = { + sourceCodeUnits: 50 * 1024 * 1024, + structuralTokens: 500_000, + nestingDepth: 256, + cells: 1000, + outputs: 10_000, + displayItems: 20_000, + multilineParts: 100_000 +} as const + +export type IpynbMemoryLimits = Readonly<{ + sourceCodeUnits: number + structuralTokens: number + nestingDepth: number + cells: number + outputs: number + displayItems: number + multilineParts: number +}> + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function assertIpynbJsonWithinMemoryLimits( + content: string, + limits: IpynbMemoryLimits = IPYNB_MEMORY_LIMITS +): void { + if (content.length > limits.sourceCodeUnits) { + throw new Error('Notebook exceeds the safe source size limit') + } + + assertJsonTextStructureWithinLimits(content, { + structuralTokens: limits.structuralTokens, + nestingDepth: limits.nestingDepth + }) +} + +export function assertIpynbShapeWithinMemoryLimits( + cells: readonly unknown[], + limits: IpynbMemoryLimits = IPYNB_MEMORY_LIMITS +): void { + if (cells.length > limits.cells) { + throw new Error('Notebook exceeds the safe cell limit') + } + + let outputCount = 0 + let displayItemCount = 0 + let multilinePartCount = 0 + const claimMultilineParts = (value: unknown): void => { + if (!Array.isArray(value)) { + return + } + multilinePartCount += value.length + if (multilinePartCount > limits.multilineParts) { + throw new Error('Notebook exceeds the safe multiline fragment limit') + } + } + + for (const cell of cells) { + if (!isRecord(cell)) { + continue + } + claimMultilineParts(cell.source) + if (!Array.isArray(cell.outputs)) { + continue + } + outputCount += cell.outputs.length + if (outputCount > limits.outputs) { + throw new Error('Notebook exceeds the safe output limit') + } + for (const output of cell.outputs) { + if (!isRecord(output)) { + continue + } + claimMultilineParts(output.text) + claimMultilineParts(output.traceback) + if (!isRecord(output.data)) { + continue + } + for (const key in output.data) { + if (!Object.prototype.hasOwnProperty.call(output.data, key)) { + continue + } + displayItemCount += 1 + if (displayItemCount > limits.displayItems) { + throw new Error('Notebook exceeds the safe display item limit') + } + } + } + } +} diff --git a/src/renderer/src/components/editor/ipynb-parse.test.ts b/src/renderer/src/components/editor/ipynb-parse.test.ts index 8178bed2355..e7835526c2e 100644 --- a/src/renderer/src/components/editor/ipynb-parse.test.ts +++ b/src/renderer/src/components/editor/ipynb-parse.test.ts @@ -11,6 +11,7 @@ import { updateIpynbCellSource, updateIpynbCellSources } from './ipynb-parse' +import { IPYNB_MEMORY_LIMITS } from './ipynb-json-admission' afterEach(() => { vi.restoreAllMocks() @@ -89,6 +90,18 @@ describe('ipynb parsing', () => { expect(() => parseIpynb('{}')).toThrow('Notebook is missing a cells array') }) + it('rejects renderer-amplifying cell counts before projecting cells', () => { + const content = JSON.stringify({ + cells: Array.from({ length: IPYNB_MEMORY_LIMITS.cells + 1 }, () => ({ + cell_type: 'raw', + metadata: {}, + source: [] + })) + }) + + expect(() => parseIpynb(content)).toThrow('safe cell limit') + }) + it('serializes cell source edits while preserving notebook metadata', () => { const content = JSON.stringify({ nbformat: 4, diff --git a/src/renderer/src/components/editor/ipynb-parse.ts b/src/renderer/src/components/editor/ipynb-parse.ts index b10aa5f2df5..a5ca9e80525 100644 --- a/src/renderer/src/components/editor/ipynb-parse.ts +++ b/src/renderer/src/components/editor/ipynb-parse.ts @@ -2,6 +2,11 @@ in one module makes nbformat preservation easier to audit while the notebook editor model is still small. */ import { createBrowserUuid } from '@/lib/browser-uuid' +import { + assertIpynbJsonWithinMemoryLimits, + assertIpynbShapeWithinMemoryLimits, + IPYNB_MEMORY_LIMITS +} from './ipynb-json-admission' export type IpynbCellKind = 'code' | 'markdown' | 'raw' @@ -65,12 +70,12 @@ function isRecord(value: unknown): value is Record { export function concatIpynbMultilineString(value: unknown): string { if (Array.isArray(value)) { - let result = '' + const parts: string[] = [] for (let i = 0; i < value.length; i += 1) { const item = String(value[i] ?? '') - result += i < value.length - 1 && !item.endsWith('\n') ? `${item}\n` : item + parts.push(i < value.length - 1 && !item.endsWith('\n') ? `${item}\n` : item) } - return result.replace(/\r\n/g, '\n') + return parts.join('').replace(/\r\n/g, '\n') } return String(value ?? '').replace(/\r\n/g, '\n') } @@ -183,16 +188,10 @@ function parseCell(rawCell: unknown, fallbackLanguage: string): IpynbCell | null } export function parseIpynb(content: string): ParsedIpynb { - const parsed = JSON.parse(content) as unknown - if (!isRecord(parsed)) { - throw new Error('Notebook root must be a JSON object') - } - if (!Array.isArray(parsed.cells)) { - throw new Error('Notebook is missing a cells array') - } + const parsed = parseNotebookRoot(content) const language = getPreferredLanguage(parsed) - const cells = parsed.cells + const cells = (parsed.cells as unknown[]) .map((cell) => parseCell(cell, language)) .filter((cell): cell is IpynbCell => cell !== null) @@ -208,6 +207,9 @@ export function parseIpynb(content: string): ParsedIpynb { } function splitIpynbSource(source: string): string[] { + if (source.length > IPYNB_MEMORY_LIMITS.sourceCodeUnits) { + throw new Error('Notebook cell source exceeds the safe size limit') + } if (!source) { return [] } @@ -218,15 +220,22 @@ function splitIpynbSource(source: string): string[] { continue } lines.push(source.slice(lineStart, index + 1)) + if (lines.length > IPYNB_MEMORY_LIMITS.multilineParts) { + throw new Error('Notebook cell source exceeds the safe line limit') + } lineStart = index + 1 } if (lineStart < source.length) { lines.push(source.slice(lineStart)) + if (lines.length > IPYNB_MEMORY_LIMITS.multilineParts) { + throw new Error('Notebook cell source exceeds the safe line limit') + } } return lines } function parseNotebookRoot(content: string): Record { + assertIpynbJsonWithinMemoryLimits(content) const parsed = JSON.parse(content) as unknown if (!isRecord(parsed)) { throw new Error('Notebook root must be a JSON object') @@ -234,6 +243,7 @@ function parseNotebookRoot(content: string): Record { if (!Array.isArray(parsed.cells)) { throw new Error('Notebook is missing a cells array') } + assertIpynbShapeWithinMemoryLimits(parsed.cells) return parsed } @@ -246,7 +256,9 @@ function ensureCell(root: Record, index: number): Record): string { - return `${JSON.stringify(root, null, 1)}\n` + const serialized = `${JSON.stringify(root, null, 1)}\n` + assertIpynbJsonWithinMemoryLimits(serialized) + return serialized } export function updateIpynbCellSource(content: string, index: number, source: string): string { @@ -300,6 +312,9 @@ export function insertIpynbCell( ): string { const root = parseNotebookRoot(content) const cells = root.cells as unknown[] + if (cells.length >= IPYNB_MEMORY_LIMITS.cells) { + throw new Error('Notebook exceeds the safe cell limit') + } const nextCell: Record = { cell_type: kind, id: createBrowserUuid(), diff --git a/src/renderer/src/components/editor/local-image-blob-retention.test.ts b/src/renderer/src/components/editor/local-image-blob-retention.test.ts new file mode 100644 index 00000000000..b16e2dea103 --- /dev/null +++ b/src/renderer/src/components/editor/local-image-blob-retention.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from 'vitest' +import { LocalImageBlobRetention, MAX_LOCAL_IMAGE_BLOB_BYTES } from './local-image-blob-retention' + +describe('LocalImageBlobRetention', () => { + it('evicts oldest blobs until aggregate retained bytes are bounded', () => { + const revoke = vi.fn() + const retention = new LocalImageBlobRetention(revoke) + + retention.set('first', { url: 'blob:first', bytes: MAX_LOCAL_IMAGE_BLOB_BYTES - 1 }) + retention.set('second', { url: 'blob:second', bytes: 2 }) + + expect(retention.has('first')).toBe(false) + expect(retention.get('second')).toBe('blob:second') + expect(retention.retainedBytes).toBe(2) + expect(revoke).toHaveBeenCalledWith('blob:first') + }) +}) diff --git a/src/renderer/src/components/editor/local-image-blob-retention.ts b/src/renderer/src/components/editor/local-image-blob-retention.ts new file mode 100644 index 00000000000..479bfdec4b4 --- /dev/null +++ b/src/renderer/src/components/editor/local-image-blob-retention.ts @@ -0,0 +1,61 @@ +export const MAX_LOCAL_IMAGE_BLOB_ENTRIES = 100 +export const MAX_LOCAL_IMAGE_BLOB_BYTES = 128 * 1024 * 1024 + +export type RetainedLocalImageBlob = { + url: string + bytes: number +} + +export class LocalImageBlobRetention { + private readonly entries = new Map() + private retained = 0 + + constructor(private readonly revoke: (url: string) => void) {} + + get(key: string): string | undefined { + return this.entries.get(key)?.url + } + + has(key: string): boolean { + return this.entries.has(key) + } + + set(key: string, entry: RetainedLocalImageBlob): void { + const previous = this.entries.get(key) + if (previous) { + this.entries.delete(key) + this.retained -= previous.bytes + if (previous.url !== entry.url) { + this.revoke(previous.url) + } + } + this.entries.set(key, entry) + this.retained += entry.bytes + while ( + this.entries.size > MAX_LOCAL_IMAGE_BLOB_ENTRIES || + this.retained > MAX_LOCAL_IMAGE_BLOB_BYTES + ) { + const oldestKey = this.entries.keys().next().value + if (oldestKey === undefined) { + break + } + const oldest = this.entries.get(oldestKey) + this.entries.delete(oldestKey) + this.retained -= oldest?.bytes ?? 0 + if (oldest) { + this.revoke(oldest.url) + } + } + } + + clear(): RetainedLocalImageBlob[] { + const stale = Array.from(this.entries.values()) + this.entries.clear() + this.retained = 0 + return stale + } + + get retainedBytes(): number { + return this.retained + } +} diff --git a/src/renderer/src/components/editor/local-image-load-admission.test.ts b/src/renderer/src/components/editor/local-image-load-admission.test.ts new file mode 100644 index 00000000000..c6ec0416412 --- /dev/null +++ b/src/renderer/src/components/editor/local-image-load-admission.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { + LocalImageLoadAdmission, + MAX_ACTIVE_LOCAL_IMAGE_LOADS, + MAX_ADMITTED_LOCAL_IMAGE_LOADS +} from './local-image-load-admission' + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 6; index += 1) { + await Promise.resolve() + } +} + +describe('LocalImageLoadAdmission', () => { + it('starts only the bounded number of image reads concurrently', async () => { + const admission = new LocalImageLoadAdmission() + const releases: (() => void)[] = [] + const task = vi.fn( + () => + new Promise((resolve) => { + releases.push(() => resolve(releases.length)) + }) + ) + + const loads = Array.from({ length: MAX_ACTIVE_LOCAL_IMAGE_LOADS + 1 }, () => + admission.admit(task) + ) + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(MAX_ACTIVE_LOCAL_IMAGE_LOADS) + + releases[0]?.() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(MAX_ACTIVE_LOCAL_IMAGE_LOADS + 1) + + for (const release of releases) { + release() + } + await Promise.all(loads) + }) + + it('rejects excess admission and can release every queued load', async () => { + const admission = new LocalImageLoadAdmission() + const releases: (() => void)[] = [] + const admitted = Array.from({ length: MAX_ADMITTED_LOCAL_IMAGE_LOADS }, () => + admission.admit( + () => + new Promise((resolve) => { + releases.push(resolve) + }) + ) + ) + + await flushMicrotasks() + expect(admission.admit(() => Promise.resolve())).toBeNull() + admission.clearPending() + for (const release of releases) { + release() + } + await Promise.all(admitted) + }) +}) diff --git a/src/renderer/src/components/editor/local-image-load-admission.ts b/src/renderer/src/components/editor/local-image-load-admission.ts new file mode 100644 index 00000000000..0e60b1bc034 --- /dev/null +++ b/src/renderer/src/components/editor/local-image-load-admission.ts @@ -0,0 +1,62 @@ +export const MAX_ACTIVE_LOCAL_IMAGE_LOADS = 2 +export const MAX_ADMITTED_LOCAL_IMAGE_LOADS = 100 + +type PendingLoad = { + start: () => void + cancel: () => void +} + +export class LocalImageLoadAdmission { + private active = 0 + private readonly pending: PendingLoad[] = [] + + admit(task: () => Promise): Promise | null { + if (this.active + this.pending.length >= MAX_ADMITTED_LOCAL_IMAGE_LOADS) { + return null + } + let resolveResult!: (value: T | null) => void + let rejectResult!: (error: unknown) => void + const result = new Promise((resolve, reject) => { + resolveResult = resolve + rejectResult = reject + }) + const pendingLoad = { + start: () => { + this.active += 1 + let operation: Promise + try { + operation = task() + } catch (error) { + this.active -= 1 + rejectResult(error) + this.drain() + return + } + void operation.then(resolveResult, rejectResult).finally(() => { + this.active -= 1 + this.drain() + }) + }, + cancel: () => resolveResult(null) + } + this.pending.push(pendingLoad) + this.drain() + return result + } + + clearPending(): void { + for (const load of this.pending.splice(0)) { + load.cancel() + } + } + + private drain(): void { + while (this.active < MAX_ACTIVE_LOCAL_IMAGE_LOADS) { + const next = this.pending.shift() + if (!next) { + return + } + next.start() + } + } +} diff --git a/src/renderer/src/components/editor/markdown-doc-links.test.ts b/src/renderer/src/components/editor/markdown-doc-links.test.ts index d5cfaade6e7..0c4b5c00912 100644 --- a/src/renderer/src/components/editor/markdown-doc-links.test.ts +++ b/src/renderer/src/components/editor/markdown-doc-links.test.ts @@ -71,6 +71,10 @@ describe('splitMarkdownDocLinkText', () => { }) describe('resolveMarkdownDocLink', () => { + it('shares one immutable index for consumers of the same document snapshot', () => { + expect(createMarkdownDocumentIndex(documents)).toBe(createMarkdownDocumentIndex(documents)) + }) + it('resolves basename links', () => { const result = resolveMarkdownDocLink('setup-guide', createMarkdownDocumentIndex(documents)) expect(result.status).toBe('resolved') diff --git a/src/renderer/src/components/editor/markdown-doc-links.ts b/src/renderer/src/components/editor/markdown-doc-links.ts index 82cd7133b38..37c8ce93836 100644 --- a/src/renderer/src/components/editor/markdown-doc-links.ts +++ b/src/renderer/src/components/editor/markdown-doc-links.ts @@ -101,7 +101,13 @@ function resolveMatches(matches: MarkdownDocument[] | undefined): MarkdownDocLin : { status: 'ambiguous', matches } } +const markdownDocumentIndexes = new WeakMap() + export function createMarkdownDocumentIndex(documents: MarkdownDocument[]): MarkdownDocumentIndex { + const cached = markdownDocumentIndexes.get(documents) + if (cached) { + return cached + } const byName = new Map() const byRelativePath = new Map() const byRelativePathWithoutExtension = new Map() @@ -116,7 +122,9 @@ export function createMarkdownDocumentIndex(documents: MarkdownDocument[]): Mark ) } - return { byName, byRelativePath, byRelativePathWithoutExtension } + const index = { byName, byRelativePath, byRelativePathWithoutExtension } + markdownDocumentIndexes.set(documents, index) + return index } export function resolveMarkdownDocLink( diff --git a/src/renderer/src/components/editor/markdown-document-list-request.test.ts b/src/renderer/src/components/editor/markdown-document-list-request.test.ts index 085b8599fb8..96e6bd1c528 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.test.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.test.ts @@ -3,8 +3,10 @@ import type { MarkdownDocument } from '../../../../shared/types' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { getMarkdownDocumentListRequestKey, + MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT, requestSharedMarkdownDocumentList } from './markdown-document-list-request' +import { MarkdownDocumentListingCapacityError } from '../../../../shared/markdown-document-listing-limits' function context(overrides: Partial = {}): RuntimeFileOperationArgs { return { @@ -173,4 +175,29 @@ describe('shared Markdown document list requests', () => { ) expect(load).toHaveBeenCalledTimes(2) }) + + it('rejects the first distinct scan beyond the global in-flight cap', async () => { + const pending = Array.from({ length: MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT }, () => + deferred() + ) + const load = vi.fn((_context: RuntimeFileOperationArgs, rootPath: string) => { + const index = Number(rootPath.slice('/route-'.length)) + return pending[index].promise + }) + const requests = pending.map((_entry, index) => + requestSharedMarkdownDocumentList(context(), `/route-${index}`, {}, load) + ) + + await expect( + requestSharedMarkdownDocumentList(context(), '/one-too-many', {}, load) + ).rejects.toBeInstanceOf(MarkdownDocumentListingCapacityError) + expect(load).toHaveBeenCalledTimes(MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT) + + for (const entry of pending) { + entry.resolve([]) + } + await expect(Promise.all(requests)).resolves.toEqual( + Array.from({ length: MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT }, () => []) + ) + }) }) diff --git a/src/renderer/src/components/editor/markdown-document-list-request.ts b/src/renderer/src/components/editor/markdown-document-list-request.ts index 7e9cc0f32b3..9d55a502637 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.ts @@ -3,6 +3,7 @@ import { listRuntimeMarkdownDocuments, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { MarkdownDocumentListingCapacityError } from '../../../../shared/markdown-document-listing-limits' type MarkdownDocumentListLoader = ( context: RuntimeFileOperationArgs, @@ -19,7 +20,9 @@ type InFlightMarkdownDocumentList = { } const MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS = 30_000 +export const MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT = 16 const inFlightMarkdownDocumentLists = new Map() +let activeMarkdownDocumentListLoads = 0 export function getMarkdownDocumentListRequestKey( context: RuntimeFileOperationArgs, @@ -54,10 +57,22 @@ export function requestSharedMarkdownDocumentList( if (existing && !options.requireFresh) { return existing.request } + if (activeMarkdownDocumentListLoads >= MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT) { + return Promise.reject(new MarkdownDocumentListingCapacityError()) + } // Why: split Markdown panes mount together and otherwise launch identical // whole-worktree local/SSH scans; mutation refreshes bypass older snapshots. - const request = load(context, rootPath).finally(() => { + activeMarkdownDocumentListLoads += 1 + let loaded: Promise + try { + loaded = load(context, rootPath) + } catch (error) { + activeMarkdownDocumentListLoads -= 1 + return Promise.reject(error) + } + const request = loaded.finally(() => { + activeMarkdownDocumentListLoads -= 1 if (inFlightMarkdownDocumentLists.get(key)?.request === request) { inFlightMarkdownDocumentLists.delete(key) } diff --git a/src/renderer/src/components/editor/markdown-document-worktree-retention.test.ts b/src/renderer/src/components/editor/markdown-document-worktree-retention.test.ts new file mode 100644 index 00000000000..50e22b39ccf --- /dev/null +++ b/src/renderer/src/components/editor/markdown-document-worktree-retention.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import type { MarkdownDocument } from '../../../../shared/types' +import { + retainMarkdownDocumentWorktreeSnapshot, + type MarkdownDocumentWorktreeSnapshot +} from './markdown-document-worktree-retention' + +function documents(name: string): MarkdownDocument[] { + return [ + { + filePath: `/repo/${name}.md`, + relativePath: `${name}.md`, + basename: `${name}.md`, + name + } + ] +} + +describe('Markdown document worktree retention', () => { + it('preserves recent under-limit worktrees and refreshes their LRU position', () => { + let snapshots = new Map() + snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'a', documents('a'), { + maxSnapshots: 2, + maxRetainedBytes: 10_000 + }) + snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'b', documents('b'), { + maxSnapshots: 2, + maxRetainedBytes: 10_000 + }) + snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'a', documents('a-new'), { + maxSnapshots: 2, + maxRetainedBytes: 10_000 + }) + snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'c', documents('c'), { + maxSnapshots: 2, + maxRetainedBytes: 10_000 + }) + + expect(Array.from(snapshots.keys())).toEqual(['a', 'c']) + expect(snapshots.get('a')?.documents[0]?.name).toBe('a-new') + }) + + it('evicts oldest snapshots when their aggregate byte budget is exceeded', () => { + let snapshots = new Map() + snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'a', documents('a'), { + maxSnapshots: 10, + maxRetainedBytes: 10_000 + }) + const oneSnapshotBytes = snapshots.get('a')?.retainedBytes ?? 0 + snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'b', documents('b'), { + maxSnapshots: 10, + maxRetainedBytes: oneSnapshotBytes + }) + + expect(Array.from(snapshots.keys())).toEqual(['b']) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-document-worktree-retention.ts b/src/renderer/src/components/editor/markdown-document-worktree-retention.ts new file mode 100644 index 00000000000..4b3db84647f --- /dev/null +++ b/src/renderer/src/components/editor/markdown-document-worktree-retention.ts @@ -0,0 +1,41 @@ +import type { MarkdownDocument } from '../../../../shared/types' +import { assertMarkdownDocumentsWithinLimit } from '../../../../shared/markdown-document-listing-limits' + +export const MARKDOWN_DOCUMENT_WORKTREE_MAX_SNAPSHOTS = 8 +export const MARKDOWN_DOCUMENT_WORKTREE_MAX_RETAINED_BYTES = 32 * 1024 * 1024 + +export type MarkdownDocumentWorktreeSnapshot = { + documents: MarkdownDocument[] + retainedBytes: number +} + +export function retainMarkdownDocumentWorktreeSnapshot( + previous: ReadonlyMap, + worktreeId: string, + documents: MarkdownDocument[], + limits: { maxSnapshots: number; maxRetainedBytes: number } = { + maxSnapshots: MARKDOWN_DOCUMENT_WORKTREE_MAX_SNAPSHOTS, + maxRetainedBytes: MARKDOWN_DOCUMENT_WORKTREE_MAX_RETAINED_BYTES + } +): Map { + const next = new Map(previous) + next.delete(worktreeId) + next.set(worktreeId, { + documents, + retainedBytes: assertMarkdownDocumentsWithinLimit(documents) + }) + + let retainedBytes = 0 + for (const snapshot of next.values()) { + retainedBytes += snapshot.retainedBytes + } + while (next.size > limits.maxSnapshots || retainedBytes > limits.maxRetainedBytes) { + const oldestKey = next.keys().next().value + if (typeof oldestKey !== 'string') { + break + } + retainedBytes -= next.get(oldestKey)?.retainedBytes ?? 0 + next.delete(oldestKey) + } + return next +} diff --git a/src/renderer/src/components/editor/markdown-export-extract.test.ts b/src/renderer/src/components/editor/markdown-export-extract.test.ts index f10a8289c44..9494b10accf 100644 --- a/src/renderer/src/components/editor/markdown-export-extract.test.ts +++ b/src/renderer/src/components/editor/markdown-export-extract.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getActiveMarkdownExportPayload } from './markdown-export-extract' +import { getActiveMarkdownExportPayload, inlineBlobImageSources } from './markdown-export-extract' vi.mock('@/store', () => ({ useAppStore: { @@ -14,10 +14,11 @@ describe('getActiveMarkdownExportPayload', () => { vi.clearAllMocks() vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }) - }) + vi + .fn() + .mockResolvedValue( + new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } }) + ) ) const { useAppStore } = await import('@/store') vi.mocked(useAppStore.getState).mockReturnValue({ @@ -66,4 +67,30 @@ describe('getActiveMarkdownExportPayload', () => { }) ).rejects.toThrow('Failed to inline image for PDF export') }) + + it('rejects streamed image bytes before the rendered fragment can exceed its cap', async () => { + let cancelled = false + vi.mocked(fetch).mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(12)) + controller.enqueue(new Uint8Array(12)) + }, + cancel() { + cancelled = true + } + }), + { headers: { 'content-type': 'image/png' } } + ) + ) + const root = document.createElement('div') + root.innerHTML = '' + const prefixOnly = ''.length + + await expect(inlineBlobImageSources(root, prefixOnly + 16)).rejects.toThrow( + 'HTML export exceeds the PDF memory limit' + ) + expect(cancelled).toBe(true) + }) }) diff --git a/src/renderer/src/components/editor/markdown-export-extract.ts b/src/renderer/src/components/editor/markdown-export-extract.ts index a0ec5ebb2fe..23d73fa27e6 100644 --- a/src/renderer/src/components/editor/markdown-export-extract.ts +++ b/src/renderer/src/components/editor/markdown-export-extract.ts @@ -1,5 +1,15 @@ import { useAppStore } from '@/store' import { detectLanguage } from '@/lib/language-detect' +import { + assertHtmlToPdfInputWithinMemoryLimit, + HTML_TO_PDF_MAX_INPUT_BYTES, + HTML_TO_PDF_MEMORY_LIMIT_ERROR +} from '../../../../shared/html-to-pdf-memory-limit' +import { + FetchResponseBodyTooLargeError, + readFetchResponseBytesWithinLimit +} from '../../../../shared/fetch-response-body' +import { measureUtf8ByteLength } from '../../../../shared/memory-safety/utf8-byte-limits' import { buildMarkdownExportHtml } from './markdown-export-html' export type MarkdownExportPayload = { @@ -65,6 +75,15 @@ export async function getActiveMarkdownExportPayload({ return null } + const title = basenameWithoutExt(activeFile.relativePath || activeFile.filePath) + const wrapper = buildMarkdownExportHtml({ title, renderedHtml: '' }) + const wrapperBytes = measureUtf8ByteLength(wrapper, { + stopAfterBytes: HTML_TO_PDF_MAX_INPUT_BYTES + }) + if (wrapperBytes.exceededLimit) { + throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR) + } + const clone = subtree.cloneNode(true) as Element for (const selector of UI_ONLY_SELECTORS) { for (const node of clone.querySelectorAll(selector)) { @@ -73,51 +92,77 @@ export async function getActiveMarkdownExportPayload({ } // Why: local-image previews use renderer-scoped blob URLs; the hidden PDF // window cannot dereference them, so embed the bytes before export. - await inlineBlobImageSources(clone) + await inlineBlobImageSources(clone, HTML_TO_PDF_MAX_INPUT_BYTES - wrapperBytes.byteLength) const renderedHtml = clone.innerHTML.trim() if (!renderedHtml) { return null } - const title = basenameWithoutExt(activeFile.relativePath || activeFile.filePath) const html = buildMarkdownExportHtml({ title, renderedHtml }) + assertHtmlToPdfInputWithinMemoryLimit(html) return { title, html } } -async function inlineBlobImageSources(root: Element): Promise { - const images = Array.from(root.querySelectorAll('img[src^="blob:"]')) - await Promise.all( - images.map(async (image) => { - const src = image.getAttribute('src') - if (!src) { - return - } - image.setAttribute('src', await readBlobImageAsDataUrl(src)) - }) - ) +export async function inlineBlobImageSources( + root: Element, + maxFragmentBytes: number +): Promise { + for (const image of root.querySelectorAll('img[src^="blob:"]')) { + const src = image.getAttribute('src') + if (!src) { + continue + } + await inlineBlobImageSource(root, image, src, maxFragmentBytes) + } } -async function readBlobImageAsDataUrl(src: string): Promise { +async function inlineBlobImageSource( + root: Element, + image: HTMLImageElement, + src: string, + maxFragmentBytes: number +): Promise { try { const response = await fetch(src) if (!response.ok) { throw new Error('Unable to fetch blob image') } - const blob = await response.blob() - const bytes = new Uint8Array(await blob.arrayBuffer()) - return `data:${blob.type || 'application/octet-stream'};base64,${bytesToBase64(bytes)}` + const mediaType = normalizedMediaType(response.headers.get('content-type')) + const prefix = `data:${mediaType};base64,` + image.setAttribute('src', prefix) + const prefixMeasurement = measureUtf8ByteLength(root.innerHTML, { + stopAfterBytes: maxFragmentBytes + }) + if (prefixMeasurement.exceededLimit) { + throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR) + } + const availableBase64Characters = maxFragmentBytes - prefixMeasurement.byteLength + const maxImageBytes = Math.floor(availableBase64Characters / 4) * 3 + const bytes = await readFetchResponseBytesWithinLimit(response, maxImageBytes) + image.setAttribute('src', `${prefix}${bytesToBase64(bytes)}`) } catch (error) { + if ( + error instanceof FetchResponseBodyTooLargeError || + (error instanceof Error && error.message === HTML_TO_PDF_MEMORY_LIMIT_ERROR) + ) { + throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR) + } const message = error instanceof Error ? error.message : String(error) throw new Error(`Failed to inline image for PDF export: ${message}`) } } +function normalizedMediaType(value: string | null): string { + return new Blob([], { type: value ?? '' }).type || 'application/octet-stream' +} + function bytesToBase64(bytes: Uint8Array): string { - let binary = '' - const chunkSize = 0x8000 + const chunks: string[] = [] + const chunkSize = 3 * 8192 for (let index = 0; index < bytes.length; index += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)) + const binary = String.fromCharCode(...bytes.subarray(index, index + chunkSize)) + chunks.push(btoa(binary)) } - return btoa(binary) + return chunks.join('') } diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-completions.test.ts b/src/renderer/src/components/editor/monaco-markdown-doc-completions.test.ts new file mode 100644 index 00000000000..a13470cb515 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-markdown-doc-completions.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { MarkdownDocument } from '../../../../shared/types' +import { assertMarkdownDocumentsWithinLimit } from '../../../../shared/markdown-document-listing-limits' +import { + clearMarkdownDocCompletionDocuments, + getMarkdownCompletionRetentionForTests, + MARKDOWN_COMPLETION_MAX_MODELS, + MARKDOWN_COMPLETION_MAX_SCOPES, + resetMarkdownCompletionRetentionForTests, + setMarkdownDocCompletionDocuments +} from './monaco-markdown-doc-completions' + +function documents(scope: string): MarkdownDocument[] { + return [ + { + filePath: `/repo/${scope}.md`, + relativePath: `${scope}.md`, + basename: `${scope}.md`, + name: scope + } + ] +} + +afterEach(() => { + resetMarkdownCompletionRetentionForTests() +}) + +describe('Monaco Markdown completion retention', () => { + it('stores one document snapshot for every mounted model in the same worktree scope', () => { + setMarkdownDocCompletionDocuments('model-a', 'worktree-a', documents('old')) + const freshDocuments = documents('fresh') + setMarkdownDocCompletionDocuments('model-b', 'worktree-a', freshDocuments) + + expect(getMarkdownCompletionRetentionForTests()).toEqual({ + models: 2, + scopes: 1, + retainedBytes: assertMarkdownDocumentsWithinLimit(freshDocuments) + }) + + clearMarkdownDocCompletionDocuments('model-a') + expect(getMarkdownCompletionRetentionForTests()).toMatchObject({ models: 1, scopes: 1 }) + clearMarkdownDocCompletionDocuments('model-b') + expect(getMarkdownCompletionRetentionForTests()).toEqual({ + models: 0, + scopes: 0, + retainedBytes: 0 + }) + }) + + it('evicts oldest scopes and model associations at their exact caps', () => { + for (let index = 0; index <= MARKDOWN_COMPLETION_MAX_SCOPES; index += 1) { + setMarkdownDocCompletionDocuments(`model-${index}`, `scope-${index}`, documents(`${index}`)) + } + expect(getMarkdownCompletionRetentionForTests()).toMatchObject({ + models: MARKDOWN_COMPLETION_MAX_SCOPES, + scopes: MARKDOWN_COMPLETION_MAX_SCOPES + }) + + resetMarkdownCompletionRetentionForTests() + for (let index = 0; index <= MARKDOWN_COMPLETION_MAX_MODELS; index += 1) { + setMarkdownDocCompletionDocuments(`model-${index}`, 'shared-scope', documents('shared')) + } + expect(getMarkdownCompletionRetentionForTests()).toMatchObject({ + models: MARKDOWN_COMPLETION_MAX_MODELS, + scopes: 1 + }) + }) +}) diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts index 882b011e9b3..d457004553f 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts @@ -5,12 +5,72 @@ import { getMarkdownDocCompletionContext, getMarkdownDocCompletionDocuments } from './markdown-doc-completions' +import { assertMarkdownDocumentsWithinLimit } from '../../../../shared/markdown-document-listing-limits' type MonacoApi = Parameters[1] +type CompletionScope = { + documents: MarkdownDocument[] + modelKeys: Set + retainedBytes: number +} + +export const MARKDOWN_COMPLETION_MAX_MODELS = 256 +export const MARKDOWN_COMPLETION_MAX_SCOPES = 32 +export const MARKDOWN_COMPLETION_MAX_RETAINED_BYTES = 64 * 1024 * 1024 + let provider: IDisposable | null = null let providerMonaco: MonacoApi | null = null -const documentsByModel = new Map() +const scopeKeyByModel = new Map() +const completionScopes = new Map() +let retainedBytes = 0 + +function deleteCompletionScope(scopeKey: string, scope: CompletionScope): void { + completionScopes.delete(scopeKey) + retainedBytes -= scope.retainedBytes + for (const modelKey of scope.modelKeys) { + scopeKeyByModel.delete(modelKey) + } +} + +function removeModel(modelKey: string): void { + const scopeKey = scopeKeyByModel.get(modelKey) + if (!scopeKey) { + return + } + scopeKeyByModel.delete(modelKey) + const scope = completionScopes.get(scopeKey) + scope?.modelKeys.delete(modelKey) + if (scope && scope.modelKeys.size === 0) { + deleteCompletionScope(scopeKey, scope) + } +} + +function enforceCompletionRetentionLimits(): void { + while (scopeKeyByModel.size > MARKDOWN_COMPLETION_MAX_MODELS) { + const oldestModelKey = scopeKeyByModel.keys().next().value + if (typeof oldestModelKey !== 'string') { + break + } + removeModel(oldestModelKey) + } + while ( + completionScopes.size > MARKDOWN_COMPLETION_MAX_SCOPES || + retainedBytes > MARKDOWN_COMPLETION_MAX_RETAINED_BYTES + ) { + const oldest = completionScopes.entries().next().value + if (!oldest) { + break + } + deleteCompletionScope(oldest[0], oldest[1]) + } +} + +function clearCompletionRetention(): void { + scopeKeyByModel.clear() + completionScopes.clear() + retainedBytes = 0 +} export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void { // Why: if Monaco was torn down and re-created (e.g. window reload), the old @@ -21,7 +81,7 @@ export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void { } if (provider) { provider.dispose() - documentsByModel.clear() + clearCompletionRetention() } providerMonaco = monaco @@ -34,7 +94,8 @@ export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void { return { suggestions: [] } } - const documents = documentsByModel.get(model.uri.toString()) ?? [] + const scopeKey = scopeKeyByModel.get(model.uri.toString()) + const documents = scopeKey ? (completionScopes.get(scopeKey)?.documents ?? []) : [] const suffix = line.slice(position.column - 1) const range = { startLineNumber: position.lineNumber, @@ -60,11 +121,49 @@ export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void { export function setMarkdownDocCompletionDocuments( modelKey: string, + scopeKey: string, documents: MarkdownDocument[] ): void { - documentsByModel.set(modelKey, documents) + removeModel(modelKey) + let nextRetainedBytes: number + try { + nextRetainedBytes = assertMarkdownDocumentsWithinLimit(documents) + } catch { + return + } + + let scope = completionScopes.get(scopeKey) + if (scope) { + completionScopes.delete(scopeKey) + retainedBytes -= scope.retainedBytes + scope.documents = documents + scope.retainedBytes = nextRetainedBytes + } else { + scope = { documents, modelKeys: new Set(), retainedBytes: nextRetainedBytes } + } + scope.modelKeys.add(modelKey) + completionScopes.set(scopeKey, scope) + scopeKeyByModel.set(modelKey, scopeKey) + retainedBytes += nextRetainedBytes + enforceCompletionRetentionLimits() } export function clearMarkdownDocCompletionDocuments(modelKey: string): void { - documentsByModel.delete(modelKey) + removeModel(modelKey) +} + +export function getMarkdownCompletionRetentionForTests(): { + models: number + scopes: number + retainedBytes: number +} { + return { + models: scopeKeyByModel.size, + scopes: completionScopes.size, + retainedBytes + } +} + +export function resetMarkdownCompletionRetentionForTests(): void { + clearCompletionRetention() } diff --git a/src/renderer/src/components/editor/pending-editor-focus-request.test.ts b/src/renderer/src/components/editor/pending-editor-focus-request.test.ts new file mode 100644 index 00000000000..e3625800a6f --- /dev/null +++ b/src/renderer/src/components/editor/pending-editor-focus-request.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import type { PendingEditorFocusRequest } from '@/store/slices/editor' +import { matchesPendingEditorFocusRequest } from './pending-editor-focus-request' + +const request: PendingEditorFocusRequest = { + fileId: 'file-1', + worktreeId: 'worktree-1', + viewStateId: 'view-1', + expiresAt: 0, + token: 3 +} + +const pane = { fileId: 'file-1', worktreeId: 'worktree-1', viewStateId: 'view-1' } + +describe('matchesPendingEditorFocusRequest', () => { + it('matches the pane the handoff was opened into', () => { + expect(matchesPendingEditorFocusRequest(request, pane)).toBe(true) + }) + + it('rejects a missing request', () => { + expect(matchesPendingEditorFocusRequest(null, pane)).toBe(false) + expect(matchesPendingEditorFocusRequest(undefined, pane)).toBe(false) + }) + + it('rejects a split sibling showing the same file', () => { + expect(matchesPendingEditorFocusRequest(request, { ...pane, viewStateId: 'view-2' })).toBe( + false + ) + }) + + it('rejects the same pane id in another file or worktree', () => { + expect(matchesPendingEditorFocusRequest(request, { ...pane, fileId: 'file-2' })).toBe(false) + expect(matchesPendingEditorFocusRequest(request, { ...pane, worktreeId: 'worktree-2' })).toBe( + false + ) + }) + + it('rejects a pane that cannot identify itself', () => { + // Why: surfaces that omit the props must never swallow another pane's handoff. + expect(matchesPendingEditorFocusRequest(request, { ...pane, viewStateId: undefined })).toBe( + false + ) + expect(matchesPendingEditorFocusRequest(request, { ...pane, worktreeId: undefined })).toBe( + false + ) + }) +}) diff --git a/src/renderer/src/components/editor/pending-editor-focus-request.ts b/src/renderer/src/components/editor/pending-editor-focus-request.ts new file mode 100644 index 00000000000..8127c86f3df --- /dev/null +++ b/src/renderer/src/components/editor/pending-editor-focus-request.ts @@ -0,0 +1,20 @@ +import type { PendingEditorFocusRequest } from '@/store/slices/editor' + +/** + * True when an explicit open focus handoff belongs to this editor pane. Both the rich Markdown and + * Monaco surfaces gate on this, so a handoff is claimed (and retired) by exactly one pane — split + * siblings share a file id, and only `viewStateId` tells them apart. + */ +export function matchesPendingEditorFocusRequest( + request: PendingEditorFocusRequest | null | undefined, + pane: { fileId: string; worktreeId: string | undefined; viewStateId: string | undefined } +): boolean { + if (!request || pane.worktreeId === undefined || pane.viewStateId === undefined) { + return false + } + return ( + request.fileId === pane.fileId && + request.worktreeId === pane.worktreeId && + request.viewStateId === pane.viewStateId + ) +} diff --git a/src/renderer/src/components/editor/rich-markdown-auto-focus.ts b/src/renderer/src/components/editor/rich-markdown-auto-focus.ts index eb3aa253372..5d9a62f3d71 100644 --- a/src/renderer/src/components/editor/rich-markdown-auto-focus.ts +++ b/src/renderer/src/components/editor/rich-markdown-auto-focus.ts @@ -5,15 +5,25 @@ import type { Editor } from '@tiptap/react' * immediately (matching MonacoEditor's behavior). Guards against focus theft * from modals/dialogs and skips scrollIntoView to avoid racing with * useEditorScrollRestore. + * + * `force` marks an explicit user handoff (Explorer open): it bypasses the theft + * guard and claims DOM focus in this tick, because `commands.focus()` defers the + * real `view.focus()` by a further frame. `shouldFocus` lets the caller retire a + * handoff that expired while the frame was pending. */ export function autoFocusRichEditor( nextEditor: Editor, rootEl: HTMLElement | null, - force = false + force = false, + shouldFocus: () => boolean = () => true ): () => void { + // Why: Tiptap can recreate the instance before its deferred focus lands, losing explicit handoffs. + if (force && !nextEditor.isDestroyed && shouldFocus()) { + nextEditor.view?.dom?.focus?.({ preventScroll: true }) + } let frameId: number | null = requestAnimationFrame(() => { frameId = null - if (nextEditor.isDestroyed) { + if (nextEditor.isDestroyed || !shouldFocus()) { return } const active = document.activeElement diff --git a/src/renderer/src/components/editor/rich-markdown-editor-props.ts b/src/renderer/src/components/editor/rich-markdown-editor-props.ts index 6a27e813f39..04a20c13c75 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-props.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-props.ts @@ -3,6 +3,7 @@ import type { MarkdownDocument } from '../../../../shared/types' export type RichMarkdownEditorProps = { fileId: string + viewStateId: string content: string filePath: string worktreeId: string diff --git a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts index e343e918619..0d4a629a6bb 100644 --- a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts @@ -22,7 +22,7 @@ describe('rich markdown local images', () => { ...globalThis.window.api, fs: { readFile: vi.fn().mockResolvedValue({ - content: 'AA==', + content: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', isBinary: true, mimeType: 'image/png' }) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index f99ca2a774e..b8aa8221bff 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -31,7 +31,17 @@ function deferred(): { return { promise, reject, resolve } } -function binaryPreview(content = 'AA=='): PreviewResult { +function pngBase64(width = 1, height = 1): string { + const bytes = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) + bytes.writeUInt32BE(13, 8) + bytes.write('IHDR', 12, 'ascii') + bytes.writeUInt32BE(width, 16) + bytes.writeUInt32BE(height, 20) + return bytes.toString('base64') +} + +function binaryPreview(content = pngBase64()): PreviewResult { return { content, isBinary: true, mimeType: 'image/png' } } @@ -151,6 +161,13 @@ describe('loadLocalImageSrc', () => { ).resolves.toBeNull() }) + it('rejects an inline raster dimension bomb before assigning it to an image', async () => { + const src = `data:image/png;base64,${pngBase64(32_769, 1)}` + + await expect(loadLocalImageSrc(src, '/repo/docs/readme.md')).resolves.toBeNull() + expect(URL.createObjectURL).not.toHaveBeenCalled() + }) + it('suppresses a stale pending completion after cache invalidation', async () => { const firstRead = deferred() const readFile = vi @@ -186,9 +203,9 @@ describe('loadLocalImageSrc', () => { invalidateLocalImageSrcCacheForTests() const newerLoad = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') - secondRead.resolve(binaryPreview('AQ==')) + secondRead.resolve(binaryPreview(pngBase64(2, 1))) await expect(newerLoad).resolves.toBe('blob:newer') - firstRead.resolve(binaryPreview('Ag==')) + firstRead.resolve(binaryPreview(pngBase64(3, 1))) await expect(staleLoad).resolves.toBeNull() await expect(loadLocalImageSrc('diagram.png', '/repo/docs/readme.md')).resolves.toBe( 'blob:newer' @@ -197,6 +214,14 @@ describe('loadLocalImageSrc', () => { expect(URL.revokeObjectURL).not.toHaveBeenCalledWith('blob:newer') }) + it('rejects an oversized raster header before creating a blob URL', async () => { + const readFile = vi.fn().mockResolvedValue(binaryPreview(pngBase64(32_769, 1))) + setReadFile(readFile) + + await expect(loadLocalImageSrc('bomb.png', '/repo/docs/readme.md')).resolves.toBeNull() + expect(URL.createObjectURL).not.toHaveBeenCalled() + }) + it('keeps runtime owners in separate image cache entries', async () => { const readFile = vi.fn().mockResolvedValue(binaryPreview()) vi.spyOn(URL, 'createObjectURL') @@ -223,23 +248,6 @@ describe('loadLocalImageSrc', () => { expect(readFile).toHaveBeenCalledTimes(2) }) - it('does not load an external SSH image through a replacement target', async () => { - const readFile = vi.fn().mockResolvedValue(binaryPreview()) - setReadFile(readFile) - - await expect( - loadLocalImageSrc('diagram.png', '/tmp/readme.md', null, { - settings: { activeRuntimeEnvironmentId: null }, - worktreeId: 'wt-1', - worktreePath: '/repo', - connectionId: 'ssh-2', - expectedExternalSshTargetId: 'ssh-1' - }) - ).resolves.toBeNull() - - expect(readFile).not.toHaveBeenCalled() - }) - it('does not update mounted hook state after unmount', async () => { const read = deferred() const readFile = vi.fn().mockReturnValue(read.promise) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 9a8327af090..3eeaad65dc3 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -2,15 +2,24 @@ import { useEffect, useState } from 'react' import { resolveImageAbsolutePath } from './markdown-preview-links' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { readRuntimeFilePreview } from '@/runtime/runtime-file-client' +import { + LocalImageBlobRetention, + MAX_LOCAL_IMAGE_BLOB_BYTES, + type RetainedLocalImageBlob +} from './local-image-blob-retention' +import { LocalImageLoadAdmission } from './local-image-load-admission' +import { decodeBase64Bytes } from './base64-byte-decoder' +import { assertRasterImagePreviewWithinLimits } from '../../../../shared/raster-image-preview-limits' +import { validateRasterImageDataUri } from '../../../../shared/image-data-uri' // Why: the renderer is served from http://localhost in dev mode, so file:// // URLs in tags are blocked by cross-origin restrictions. Loading images // via the existing fs.readFile IPC and converting to blob URLs bypasses this // limitation and works identically in both dev and production modes. -const BLOB_URL_CACHE_MAX_SIZE = 100 -const blobUrlCache = new Map() +const blobUrlCache = new LocalImageBlobRetention((url) => URL.revokeObjectURL(url)) const inFlightBlobUrlLoads = new Map>() +const imageLoadAdmission = new LocalImageLoadAdmission() export function getLocalImageCacheKey( absolutePath: string, @@ -22,7 +31,6 @@ export function getLocalImageCacheKey( return [ runtimeEnvironmentId, runtimeContext?.connectionId ?? connectionId ?? 'local', - runtimeContext?.expectedExternalSshTargetId ?? '', runtimeContext?.worktreeId ?? 'unknown-worktree', absolutePath ].join('\0') @@ -32,53 +40,45 @@ export function getLocalImageCacheKey( // the cache grows without bound and leaks memory. We evict the oldest entry // (Map iteration order is insertion order) and revoke its blob URL so the // browser can free the underlying data. -function cacheBlobUrl(key: string, url: string): void { - const previousUrl = blobUrlCache.get(key) - if (previousUrl !== undefined) { - blobUrlCache.delete(key) - if (previousUrl !== url) { - // Why: cache replacements must release the superseded Blob even when - // they come from rare stale state or future loader changes. - URL.revokeObjectURL(previousUrl) - } - } - blobUrlCache.set(key, url) - if (blobUrlCache.size > BLOB_URL_CACHE_MAX_SIZE) { - const oldest = blobUrlCache.keys().next().value - if (oldest !== undefined) { - const oldUrl = blobUrlCache.get(oldest) - blobUrlCache.delete(oldest) - if (oldUrl) { - URL.revokeObjectURL(oldUrl) - } - } - } +function cacheBlobUrl(key: string, entry: RetainedLocalImageBlob): void { + blobUrlCache.set(key, entry) } const cacheListeners = new Set<() => void>() let cacheGeneration = 0 -const pendingBlobUrlRevocations = new Set() +const pendingBlobUrlRevocations = new Map() +let pendingBlobUrlRevocationBytes = 0 let pendingBlobUrlRevocationTimer: ReturnType | null = null -function base64ToBlobUrl(base64: string, mimeType: string): string { - const binary = atob(base64.replace(/\s/g, '')) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i) - } - return URL.createObjectURL(new Blob([bytes], { type: mimeType })) +function base64ToBlobUrl(base64: string, mimeType: string): RetainedLocalImageBlob { + const bytes = decodeBase64Bytes(base64) + assertRasterImagePreviewWithinLimits(bytes, mimeType) + return { url: URL.createObjectURL(new Blob([bytes], { type: mimeType })), bytes: bytes.length } } function revokePendingBlobUrls(): void { pendingBlobUrlRevocationTimer = null - for (const url of pendingBlobUrlRevocations) { + for (const url of pendingBlobUrlRevocations.keys()) { URL.revokeObjectURL(url) } pendingBlobUrlRevocations.clear() + pendingBlobUrlRevocationBytes = 0 } -function scheduleBlobUrlRevocation(urls: string[]): void { - for (const url of urls) { - pendingBlobUrlRevocations.add(url) +function scheduleBlobUrlRevocation(entries: RetainedLocalImageBlob[]): void { + for (const { url, bytes } of entries) { + const previousBytes = pendingBlobUrlRevocations.get(url) ?? 0 + pendingBlobUrlRevocations.set(url, bytes) + pendingBlobUrlRevocationBytes += bytes - previousBytes + } + while (pendingBlobUrlRevocationBytes > MAX_LOCAL_IMAGE_BLOB_BYTES) { + const oldest = pendingBlobUrlRevocations.entries().next().value + if (!oldest) { + break + } + const [url, bytes] = oldest + pendingBlobUrlRevocations.delete(url) + pendingBlobUrlRevocationBytes -= bytes + URL.revokeObjectURL(url) } if (pendingBlobUrlRevocationTimer !== null || pendingBlobUrlRevocations.size === 0) { return @@ -93,9 +93,9 @@ function scheduleBlobUrlRevocation(urls: string[]): void { // display the old data while the fresh IPC load completes, avoiding a visible // flash. The 30-second window is generous enough for even slow IPC reads. function invalidateImageCache(): void { - const staleUrls = Array.from(blobUrlCache.values()) - blobUrlCache.clear() + const staleUrls = blobUrlCache.clear() inFlightBlobUrlLoads.clear() + imageLoadAdmission.clearPending() cacheGeneration += 1 for (const listener of cacheListeners) { listener() @@ -110,6 +110,7 @@ function invalidateImageCache(): void { } function disposeImageCacheModuleState(): void { + cacheGeneration += 1 if (typeof window !== 'undefined') { window.removeEventListener('focus', invalidateImageCache) } @@ -118,10 +119,10 @@ function disposeImageCacheModuleState(): void { pendingBlobUrlRevocationTimer = null } revokePendingBlobUrls() - for (const url of blobUrlCache.values()) { + for (const { url } of blobUrlCache.clear()) { URL.revokeObjectURL(url) } - blobUrlCache.clear() + imageLoadAdmission.clearPending() inFlightBlobUrlLoads.clear() cacheListeners.clear() } @@ -147,13 +148,13 @@ export function onImageCacheInvalidated(listener: () => void): () => void { } } -function isExternalUrl(src: string): boolean { - return ( - src.startsWith('http://') || - src.startsWith('https://') || - src.startsWith('data:') || - src.startsWith('blob:') - ) +function resolveExternalImageUrl(src: string): string | null { + if (src.startsWith('data:')) { + return validateRasterImageDataUri(src) + } + return src.startsWith('http://') || src.startsWith('https://') || src.startsWith('blob:') + ? src + : null } /** @@ -178,8 +179,9 @@ export function useLocalImageSrc( if (!rawSrc) { return undefined } - if (isExternalUrl(rawSrc)) { - return rawSrc + const externalSrc = resolveExternalImageUrl(rawSrc) + if (externalSrc) { + return externalSrc } const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) if (absolutePath) { @@ -197,8 +199,9 @@ export function useLocalImageSrc( return } - if (isExternalUrl(rawSrc)) { - setDisplaySrc(rawSrc) + const externalSrc = resolveExternalImageUrl(rawSrc) + if (externalSrc) { + setDisplaySrc(externalSrc) return } @@ -248,13 +251,9 @@ export async function loadLocalImageSrc( connectionId?: string | null, runtimeContext?: Omit & { connectionId?: string | null } ): Promise { - if ( - rawSrc.startsWith('http://') || - rawSrc.startsWith('https://') || - rawSrc.startsWith('data:') || - rawSrc.startsWith('blob:') - ) { - return rawSrc + const externalSrc = resolveExternalImageUrl(rawSrc) + if (externalSrc) { + return externalSrc } const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) @@ -288,20 +287,26 @@ export function loadLocalImageAbsolutePath( } const readGeneration = cacheGeneration - const loadPromise = readImagePreview(absolutePath, connectionId, runtimeContext) + const admitted = imageLoadAdmission.admit(() => + readImagePreview(absolutePath, connectionId, runtimeContext) + ) + if (!admitted) { + return Promise.resolve(null) + } + const loadPromise = admitted .then((result) => { - if (!result.isBinary || !result.content || cacheGeneration !== readGeneration) { + if (!result?.isBinary || !result.content || cacheGeneration !== readGeneration) { // Why: local image paths must stay behind IPC/runtime authorization; // handing raw file: or relative paths back to Chromium can escape it. return null } - const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') + const entry = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') if (cacheGeneration !== readGeneration) { - URL.revokeObjectURL(url) + URL.revokeObjectURL(entry.url) return null } - cacheBlobUrl(cacheKey, url) - return url + cacheBlobUrl(cacheKey, entry) + return entry.url }) .catch(() => null) .finally(() => { @@ -319,13 +324,14 @@ export function resetLocalImageSrcStateForTests(): void { pendingBlobUrlRevocationTimer = null } revokePendingBlobUrls() - for (const url of blobUrlCache.values()) { + for (const { url } of blobUrlCache.clear()) { URL.revokeObjectURL(url) } - blobUrlCache.clear() + imageLoadAdmission.clearPending() inFlightBlobUrlLoads.clear() - cacheGeneration = 0 + cacheGeneration += 1 pendingBlobUrlRevocations.clear() + pendingBlobUrlRevocationBytes = 0 cacheListeners.clear() } diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.test.tsx b/src/renderer/src/components/editor/useMarkdownDocuments.test.tsx new file mode 100644 index 00000000000..b0b41dd9799 --- /dev/null +++ b/src/renderer/src/components/editor/useMarkdownDocuments.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { MarkdownDocument } from '../../../../shared/types' +import { MarkdownDocumentListingCapacityError } from '../../../../shared/markdown-document-listing-limits' +import type { OpenFile } from '@/store/slices/editor' +import { useMarkdownDocuments } from './useMarkdownDocuments' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + openFile: vi.fn(), + openMarkdownPreview: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn() })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + settingsForRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null }) +})) +vi.mock('./markdown-document-list-request', () => ({ + requestSharedMarkdownDocumentList: mocks.list +})) +vi.mock('./markdown-document-worktree-path-selector', () => ({ + selectMarkdownDocumentWorktreePath: (_state: unknown, worktreeId: string) => `/repo/${worktreeId}` +})) +vi.mock('@/store', () => { + const state = { + settings: {}, + openFile: mocks.openFile, + openMarkdownPreview: mocks.openMarkdownPreview + } + const useAppStore = Object.assign( + (selector: (value: typeof state) => unknown) => selector(state), + { getState: () => state } + ) + return { useAppStore } +}) + +function file(worktreeId: string): OpenFile { + return { + id: `/repo/${worktreeId}/README.md`, + filePath: `/repo/${worktreeId}/README.md`, + relativePath: 'README.md', + worktreeId, + language: 'markdown', + isDirty: false, + mode: 'edit' + } +} + +function deferred(): { + promise: Promise + reject: (error: Error) => void + resolve: (value: T) => void +} { + let reject!: (error: Error) => void + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +afterEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() +}) + +describe('useMarkdownDocuments', () => { + it('does not toast when a superseded worktree scan later exceeds capacity', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const first = deferred() + const second = deferred() + mocks.list.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + + const hook = renderHook( + ({ activeFile }) => useMarkdownDocuments(activeFile, true, 'source', vi.fn()), + { initialProps: { activeFile: file('first') } } + ) + await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(1)) + + hook.rerender({ activeFile: file('second') }) + await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(2)) + + await act(async () => { + first.reject(new MarkdownDocumentListingCapacityError()) + second.resolve([]) + await Promise.allSettled([first.promise, second.promise]) + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 5e8e5a17fb8..842f826a62a 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -1,5 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' import type { MarkdownDocument } from '../../../../shared/types' +import { + isMarkdownDocumentListingCapacityError, + MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE +} from '../../../../shared/markdown-document-listing-limits' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { statRuntimePath } from '@/runtime/runtime-file-client' @@ -12,6 +17,10 @@ import { } from './markdown-doc-links' import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector' import { requestSharedMarkdownDocumentList } from './markdown-document-list-request' +import { + retainMarkdownDocumentWorktreeSnapshot, + type MarkdownDocumentWorktreeSnapshot +} from './markdown-document-worktree-retention' type OpenMarkdownDocumentOptions = { anchor?: string | null @@ -60,8 +69,8 @@ export function useMarkdownDocuments( const openFile = useAppStore((s) => s.openFile) const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) const [markdownDocumentsByWorktree, setMarkdownDocumentsByWorktree] = useState< - Record - >({}) + Map + >(() => new Map()) const requestRef = useRef(0) const connectionId = getConnectionId(worktreeId) @@ -91,17 +100,20 @@ export function useMarkdownDocuments( if (requestRef.current !== requestId) { return } - setMarkdownDocumentsByWorktree((prev) => ({ - ...prev, - [worktreeId]: documents - })) + setMarkdownDocumentsByWorktree((prev) => + retainMarkdownDocumentWorktreeSnapshot(prev, worktreeId, documents) + ) } catch (err) { console.error('Failed to list markdown documents:', err) if (requestRef.current === requestId) { - setMarkdownDocumentsByWorktree((prev) => ({ - ...prev, - [worktreeId]: [] - })) + if (isMarkdownDocumentListingCapacityError(err)) { + toast.error(MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE, { + id: `markdown-document-listing-capacity:${worktreeId}` + }) + } + setMarkdownDocumentsByWorktree((prev) => + retainMarkdownDocumentWorktreeSnapshot(prev, worktreeId, []) + ) } } }, @@ -182,7 +194,7 @@ export function useMarkdownDocuments( }, [activeFile.id, isMarkdown, viewMode, refreshMarkdownDocuments]) const markdownDocuments = useMemo( - () => (worktreeId ? (markdownDocumentsByWorktree[worktreeId] ?? []) : []), + () => (worktreeId ? (markdownDocumentsByWorktree.get(worktreeId)?.documents ?? []) : []), [worktreeId, markdownDocumentsByWorktree] ) diff --git a/src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts b/src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts new file mode 100644 index 00000000000..a2bbf0079ec --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts @@ -0,0 +1,163 @@ +// @vitest-environment happy-dom +import type { Editor } from '@tiptap/react' +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PendingEditorFocusRequest } from '@/store/slices/editor' + +type StoreFixture = { + pendingEditorFocusRequest: PendingEditorFocusRequest | null + consumeEditorFocusRequest: ReturnType +} + +const fixture = vi.hoisted(() => ({ + store: { + pendingEditorFocusRequest: null, + consumeEditorFocusRequest: vi.fn() + } as StoreFixture, + autoFocusRichEditor: vi.fn(() => vi.fn()) +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: StoreFixture) => unknown) => selector(fixture.store) +})) + +vi.mock('./rich-markdown-auto-focus', () => ({ + autoFocusRichEditor: fixture.autoFocusRichEditor +})) + +import { useRichMarkdownPendingFocus } from './useRichMarkdownPendingFocus' + +type EditorFixture = { + editor: Editor + focus: () => void +} + +function createEditorFixture(destroyed = false): EditorFixture { + const focusListeners = new Set<() => void>() + let focused = false + return { + editor: { + isDestroyed: destroyed, + get isFocused() { + return focused + }, + on: vi.fn((event: string, listener: () => void) => { + if (event === 'focus') { + focusListeners.add(listener) + } + }), + off: vi.fn((event: string, listener: () => void) => { + if (event === 'focus') { + focusListeners.delete(listener) + } + }) + } as unknown as Editor, + focus: () => { + focused = true + for (const listener of focusListeners) { + listener() + } + } + } +} + +function pendingRequest(overrides: Partial = {}) { + return { + fileId: 'file-1', + worktreeId: 'worktree-1', + viewStateId: 'view-1', + expiresAt: Date.now() + 30_000, + token: 7, + ...overrides + } +} + +function renderPendingFocus(editor: Editor | null, viewStateId = 'view-1') { + return renderHook( + ({ nextEditor }) => + useRichMarkdownPendingFocus({ + editor: nextEditor, + fileId: 'file-1', + viewStateId, + worktreeId: 'worktree-1', + rootRef: { current: null }, + cancelAutoFocusRef: { current: null } + }), + { initialProps: { nextEditor: editor } } + ) +} + +describe('useRichMarkdownPendingFocus', () => { + afterEach(() => { + vi.useRealTimers() + fixture.store.pendingEditorFocusRequest = null + fixture.store.consumeEditorFocusRequest.mockReset() + fixture.autoFocusRichEditor.mockReset() + fixture.autoFocusRichEditor.mockReturnValue(vi.fn()) + }) + + it('consumes the request only after delayed editor focus lands', () => { + const editor = createEditorFixture() + fixture.store.pendingEditorFocusRequest = pendingRequest() + + const hook = renderPendingFocus(editor.editor) + + expect(fixture.store.consumeEditorFocusRequest).not.toHaveBeenCalled() + act(editor.focus) + expect(fixture.store.consumeEditorFocusRequest).toHaveBeenCalledWith(7) + + hook.unmount() + expect(editor.editor.off).toHaveBeenCalledWith('focus', expect.any(Function)) + }) + + it('retries a request when Tiptap replaces a destroyed editor', () => { + const destroyed = createEditorFixture(true) + const replacement = createEditorFixture() + fixture.store.pendingEditorFocusRequest = pendingRequest() + fixture.autoFocusRichEditor.mockImplementationOnce(() => { + replacement.focus() + return vi.fn() + }) + + const hook = renderPendingFocus(destroyed.editor) + expect(fixture.autoFocusRichEditor).not.toHaveBeenCalled() + + hook.rerender({ nextEditor: replacement.editor }) + + expect(fixture.store.consumeEditorFocusRequest).toHaveBeenCalledWith(7) + }) + + it('does not let another split pane claim the request', () => { + fixture.store.pendingEditorFocusRequest = pendingRequest() + + renderPendingFocus(createEditorFixture().editor, 'view-2') + + expect(fixture.autoFocusRichEditor).not.toHaveBeenCalled() + expect(fixture.store.consumeEditorFocusRequest).not.toHaveBeenCalled() + }) + + it('retires an expired request without stealing focus', () => { + fixture.store.pendingEditorFocusRequest = pendingRequest({ expiresAt: Date.now() - 1 }) + + renderPendingFocus(null) + + expect(fixture.autoFocusRichEditor).not.toHaveBeenCalled() + expect(fixture.store.consumeEditorFocusRequest).toHaveBeenCalledWith(7) + }) + + it('cancels a pending forced focus when its request expires', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-25T00:00:00Z')) + const cancelFocus = vi.fn() + fixture.store.pendingEditorFocusRequest = pendingRequest({ expiresAt: Date.now() + 1_000 }) + fixture.autoFocusRichEditor.mockReturnValue(cancelFocus) + + renderPendingFocus(createEditorFixture().editor) + act(() => { + vi.advanceTimersByTime(1_000) + }) + + expect(cancelFocus).toHaveBeenCalledOnce() + expect(fixture.store.consumeEditorFocusRequest).toHaveBeenCalledWith(7) + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownPendingFocus.ts b/src/renderer/src/components/editor/useRichMarkdownPendingFocus.ts index 372c22e6433..22635bdc7a6 100644 --- a/src/renderer/src/components/editor/useRichMarkdownPendingFocus.ts +++ b/src/renderer/src/components/editor/useRichMarkdownPendingFocus.ts @@ -2,38 +2,78 @@ import { useEffect, type RefObject } from 'react' import type { Editor } from '@tiptap/react' import { useAppStore } from '@/store' import { autoFocusRichEditor } from './rich-markdown-auto-focus' +import { matchesPendingEditorFocusRequest } from './pending-editor-focus-request' type PendingFocusOptions = { editor: Editor | null fileId: string + viewStateId: string worktreeId: string rootRef: RefObject cancelAutoFocusRef: RefObject<(() => void) | null> } /** - * Focuses the editor when the Explorer opens this document for find (issue #8083), then consumes - * the request so a later remount of the same file does not steal focus again. + * Focuses the editor when the Explorer opens this document for find (issue #8083). The request is + * scoped to one pane (`viewStateId`) so split siblings can't claim it, and stays armed until focus + * actually lands — Tiptap can replace the instance first — or until its TTL retires it, so a later + * unrelated remount of the same file never steals focus. */ export function useRichMarkdownPendingFocus({ editor, fileId, + viewStateId, worktreeId, rootRef, cancelAutoFocusRef }: PendingFocusOptions): void { const pendingEditorFocusRequest = useAppStore((s) => { const request = s.pendingEditorFocusRequest - return request?.fileId === fileId && request.worktreeId === worktreeId ? request : null + return matchesPendingEditorFocusRequest(request, { fileId, worktreeId, viewStateId }) + ? request + : null }) const consumeEditorFocusRequest = useAppStore((s) => s.consumeEditorFocusRequest) useEffect(() => { - if (!editor || !pendingEditorFocusRequest) { + if (!pendingEditorFocusRequest) { return } + if (pendingEditorFocusRequest.expiresAt <= Date.now()) { + consumeEditorFocusRequest(pendingEditorFocusRequest.token) + return + } + if (!editor || editor.isDestroyed) { + return + } + let consumed = false + const consumeIfFocused = (): void => { + if ( + consumed || + (rootRef.current?.contains(document.activeElement) !== true && !editor.isFocused) + ) { + return + } + consumed = true + consumeEditorFocusRequest(pendingEditorFocusRequest.token) + } + editor.on('focus', consumeIfFocused) cancelAutoFocusRef.current?.() - cancelAutoFocusRef.current = autoFocusRichEditor(editor, rootRef.current, true) - consumeEditorFocusRequest(pendingEditorFocusRequest.token) + cancelAutoFocusRef.current = autoFocusRichEditor( + editor, + rootRef.current, + true, + () => pendingEditorFocusRequest.expiresAt > Date.now() + ) + const expiryTimer = window.setTimeout(() => { + cancelAutoFocusRef.current?.() + cancelAutoFocusRef.current = null + consumeEditorFocusRequest(pendingEditorFocusRequest.token) + }, pendingEditorFocusRequest.expiresAt - Date.now()) + consumeIfFocused() + return () => { + window.clearTimeout(expiryTimer) + editor.off('focus', consumeIfFocused) + } }, [cancelAutoFocusRef, consumeEditorFocusRequest, editor, pendingEditorFocusRequest, rootRef]) } diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts deleted file mode 100644 index 6eaab34ec3e..00000000000 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const COMPONENT_ROOT = __dirname - -function componentSource(relativePath: string): string { - return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8') -} - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('GitHubItemDialog source host boundaries', () => { - it('does not keep the stale right-side sheet owner', () => { - const source = componentSource('GitHubItemDialog.tsx') - - expect(source).not.toContain('@/components/ui/sheet') - expect(source).not.toContain(' { - const source = componentSource('GitHubItemDialog.tsx') - const section = sourceBetween(source, 'function PRReviewersPanel', 'function isPRFileViewed') - - expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') - expect(section).toContain('useRepoAssigneesBySlug(') - expect(section).toContain('sourceSettings') - expect(section).toContain('useRepoAssignees(') - expect(section).toContain('sourceSettings') - expect(section).toContain('getActiveRuntimeTarget(sourceSettings)') - expect(section).toContain( - 'const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId)' - ) - expect(section).toContain("'github.requestPRReviewers'") - expect(section).toContain("'github.removePRReviewers'") - expect(section).toContain('resolvePullRequestRepo(item, projectOrigin)') - expect(section.match(/prRepo: reviewRepo/g)).toHaveLength(4) - expect(section).toContain('notifyWorkItemDetailsMutation(') - expect(section).toContain('{ local: false }') - }) - - it('routes edit metadata through the same task source as issue mutations', () => { - const source = componentSource('GitHubItemDialog.tsx') - const section = sourceBetween(source, 'function GHEditSection', 'const hasAttachedWorkspace') - const helperSection = sourceBetween( - source, - 'function getGitHubMutationSettings', - 'function GitHubLabelsSettingsLink' - ) - - expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') - expect(section).toContain('useRepoLabels(') - expect(section).toContain('useRepoLabelsBySlug(') - expect(section).toContain('projectOrigin?.host') - expect(section).toContain('useRepoAssignees(') - expect(section).toContain('useRepoAssigneesBySlug(') - expect(section).toContain('sourceSettings') - expect(helperSection).toContain("'github.updateIssue'") - expect(helperSection).toContain("'github.updatePRState'") - expect(helperSection).toContain("'github.project.updateIssueBySlug'") - expect(helperSection).toContain("'github.project.updatePullRequestBySlug'") - expect(helperSection).toContain("args.sourceContext?.provider === 'github'") - expect(helperSection).toContain('getTaskSourceRuntimeSettings(args.sourceContext)') - expect(helperSection).toContain( - 'getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext)' - ) - expect(helperSection).toContain('notifyWorkItemDetailsMutation(') - expect(helperSection).toContain( - "repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? '')" - ) - expect(helperSection).toContain('{ local: false }') - }) - - it('uses source-aware details routing and cache identity', () => { - const source = componentSource('GitHubItemDialog.tsx') - const cacheKeySection = sourceBetween( - source, - 'function getWorkItemDetailsCacheKey', - 'function touchWorkItemDetailsCache' - ) - const matchInvalidationSection = sourceBetween( - source, - 'function invalidateWorkItemDetailsCacheByMatch', - 'function patchCachedPRFileViewedState' - ) - - expect(source).toContain('lookupGitHubWorkItemDetailsForSource({') - expect(source).toContain('sourceContext,') - expect(cacheKeySection).toContain('sourceCacheScope') - expect(source).toContain('getTaskSourceCacheScope(sourceContext)') - expect(matchInvalidationSection).toContain( - 'if (removed) {\n workItemDetailsCacheGeneration += 1' - ) - }) - - it('treats null details as unavailable while preserving empty detail payloads', () => { - const source = componentSource('GitHubItemDialog.tsx') - const loadedSection = sourceBetween( - source, - 'const loading = !!cachedEntry?.pending && !cachedEntry?.details', - '// Why: if a cross-window mutation invalidates' - ) - const resultSection = sourceBetween(source, 'inflight', '.catch((err) => {') - - expect(loadedSection).toContain('const detailsLoaded = Boolean(cachedEntry?.details)') - expect(loadedSection).not.toContain('fetchedAt > 0') - expect(resultSection).toContain('} else if (result === null) {') - expect(resultSection).toContain('error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE') - expect(resultSection).toContain('details: result') - }) - - it('routes PR file viewed mutations through the task source context', () => { - const source = componentSource('GitHubItemDialog.tsx') - const helperSection = sourceBetween( - source, - 'function setPRFileViewedForRepo', - 'function PRViewedCheckbox' - ) - const changeSection = sourceBetween( - source, - 'const handlePRFileViewedChange = useCallback', - 'const isIssuePage = workItem?.type ===' - ) - - expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)') - expect(helperSection).toContain("'github.setPRFileViewed'") - expect(helperSection).toContain('repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)') - expect(helperSection).toContain('sourceContext: args.sourceContext') - expect(helperSection).toContain('{ local: false }') - expect(changeSection).toContain('canUseDetailsRepoContext') - expect(changeSection).toContain('repoPath: repoPath ??') - expect(changeSection).toContain('sourceContext,') - }) - - it('routes comment mutations through runtime source context when needed', () => { - const source = componentSource('GitHubItemDialog.tsx') - const helperSection = sourceBetween( - source, - 'function addIssueCommentForRepo', - 'function setPRFileViewedForRepo' - ) - - expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)') - expect(helperSection).toContain("'github.addIssueComment'") - expect(helperSection).toContain("'github.addPRReviewComment'") - expect(helperSection).toContain("'github.addPRReviewCommentReply'") - expect(helperSection).toContain('repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)') - expect(helperSection).toContain('sourceContext: args.sourceContext') - expect(helperSection).toContain('notifyWorkItemDetailsMutation(') - expect(helperSection).toContain('{ local: false }') - }) - - it('routes PR file contents and runtime viewed invalidations through the task source context', () => { - const source = componentSource('GitHubItemDialog.tsx') - const fileContentsSection = sourceBetween( - source, - 'function loadPRFileContents', - 'function setPRFileViewedForRepo' - ) - const fileContentsCacheKeySection = sourceBetween( - source, - 'function getPRFileContentCacheKey', - 'function loadPRFileContents' - ) - const listenerSection = sourceBetween(source, 'let workItemMutatedUnsub', '// Why: bounded LRU') - - expect(fileContentsCacheKeySection).toContain( - 'source:${getTaskSourceCacheScope(args.sourceContext)}' - ) - expect(fileContentsSection).toContain("'github.prFileContents'") - expect(fileContentsSection).toContain( - 'repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)' - ) - expect(fileContentsSection).toContain('sourceContext: args.sourceContext') - expect(fileContentsSection).toContain('sourceContext,') - expect(listenerSection).toContain('onGitHubWorkItemDetailsCacheMutation') - expect(source).toContain('emitGitHubWorkItemDetailsCacheMutation(args)') - expect(source).toContain('options.local !== false') - expect(source).toContain('notifyWorkItemMutated({') - }) - - it('routes merge actions through the repo owner host (#6957)', () => { - const source = componentSource('GitHubItemDialog.tsx') - const actionsSection = sourceBetween( - source, - 'function PRActionsPanel', - 'function CommentReactions' - ) - - expect(actionsSection).toContain( - 'getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)' - ) - expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)') - expect(actionsSection).toContain( - 'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ===' - ) - expect(actionsSection).toContain("'github.mergePR'") - expect(actionsSection).toContain("'github.setPRAutoMerge'") - expect(actionsSection).toContain('const prRepo = resolvePullRequestRepo(item, projectOrigin)') - expect(actionsSection).not.toContain('prRepo: item.prRepo ?? null') - expect(actionsSection).toContain( - 'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)' - ) - expect(actionsSection).toContain('sourceContext,') - expect(actionsSection).toContain('notifyWorkItemDetailsMutation(') - expect(actionsSection).toContain('{ local: false }') - }) - - it('routes check actions through the task source context', () => { - const source = componentSource('GitHubItemDialog.tsx') - const checksSection = sourceBetween( - source, - 'function ChecksTab', - 'function getGitHubMutationSettings' - ) - - expect(checksSection).toContain('sourceContext?: TaskSourceContext | null') - expect(checksSection).toContain('sourceContext,') - expect(checksSection).toContain("'github.prChecks'") - expect(checksSection).toContain("'github.rerunPRChecks'") - expect(checksSection).toContain("'github.prCheckDetails'") - expect(checksSection).toContain( - 'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)' - ) - expect(checksSection).toContain('window.api.gh.prChecks({') - expect(checksSection).toContain('window.api.gh.rerunPRChecks({') - expect(checksSection).toContain('prCheckDetails({') - }) - - it('uses hydrated work item details for the page checks tab', () => { - const source = componentSource('GitHubItemDialog.tsx') - const checksTab = sourceBetween( - source, - ' { - it('routes reviewer metadata and mutations through the PR repo owner host', () => { - const source = componentSource('PullRequestPage.tsx') - const section = sourceBetween(source, 'function PRReviewersPanel', 'function isPRFileViewed') - - expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') - expect(section).toContain('useRepoAssigneesBySlug(') - expect(section).toContain('sourceSettings') - expect(section).toContain('useRepoAssignees(') - expect(section).toContain('sourceSettings') - expect(section).toContain('getActiveRuntimeTarget(sourceSettings)') - expect(section).toContain('sourceContext,') - expect(section).toContain( - 'patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, {' - ) - expect(section).toContain( - 'const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId)' - ) - expect(section).toContain("'github.requestPRReviewers'") - expect(section).toContain("'github.removePRReviewers'") - expect(section).toContain('resolvePullRequestRepo(item, projectOrigin)') - expect(section.match(/prRepo: reviewRepo/g)).toHaveLength(4) - expect(section).toContain('notifyWorkItemDetailsMutation(') - expect(section).toContain('{ local: false }') - }) - - it('routes PR edit metadata through the same repo owner host as mutations', () => { - const source = componentSource('PullRequestPage.tsx') - const section = sourceBetween(source, 'function GHEditSection', 'function GHCommentComposer') - - expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)') - expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') - expect(section).toContain('useRepoLabels(') - expect(section).toContain('useRepoLabelsBySlug(') - expect(section).toContain('projectOrigin?.host') - expect(section).toContain('useRepoAssignees(') - expect(section).toContain('useRepoAssigneesBySlug(') - expect(section).toContain('sourceSettings') - }) - - it('source-scopes full-page optimistic work item patches', () => { - const source = componentSource('PullRequestPage.tsx') - const prAssigneesSection = sourceBetween( - source, - 'function PRAssigneesPanel', - 'function PRReviewersPanel' - ) - const prActionsSection = sourceBetween( - source, - 'function PRActionsPanel', - 'function CommentReactions' - ) - const issueEditSection = sourceBetween( - source, - 'function GHEditSection', - 'function GHCommentComposer' - ) - - expect(prAssigneesSection).toContain( - 'patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext })' - ) - expect(prActionsSection).toContain( - 'patchWorkItem(item.id, { state }, item.repoId, { sourceContext })' - ) - expect(issueEditSection).toContain( - 'patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext })' - ) - expect(issueEditSection).toContain( - 'patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext })' - ) - }) - - it('routes PR mention metadata through the PR repo owner host', () => { - const source = componentSource('PullRequestPage.tsx') - const section = sourceBetween(source, 'function ConversationTab', 'const mentionOptions') - - expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)') - expect(section).toContain('useRepoAssignees(repoPath, item.repoId, sourceSettings)') - }) - - it('uses source-aware initial details routing and cache identity', () => { - const source = componentSource('PullRequestPage.tsx') - const propsSection = sourceBetween( - source, - 'type PullRequestPageProps', - 'function formatRelativeTime' - ) - const cacheKeySection = sourceBetween( - source, - 'function getWorkItemDetailsCacheKey', - 'function touchWorkItemDetailsCache' - ) - const matchInvalidationSection = sourceBetween( - source, - 'function invalidateWorkItemDetailsCacheByMatch', - 'function patchCachedPRFileViewedState' - ) - - expect(propsSection).toContain('sourceContext?: TaskSourceContext | null') - expect(source).toContain('lookupGitHubWorkItemDetailsForSource({') - expect(source).toContain('sourceContext,') - expect(cacheKeySection).toContain('sourceCacheScope') - expect(source).toContain('getTaskSourceCacheScope(sourceContext)') - expect(matchInvalidationSection).toContain( - 'if (removed) {\n workItemDetailsCacheGeneration += 1' - ) - }) - - it('treats null details as unavailable while preserving empty detail payloads', () => { - const source = componentSource('PullRequestPage.tsx') - const loadedSection = sourceBetween( - source, - 'const loading = !!cachedEntry?.pending && !cachedEntry?.details', - '// Why: if a cross-window mutation invalidates' - ) - const resultSection = sourceBetween(source, 'inflight', '.catch((err) => {') - - expect(loadedSection).toContain('const detailsLoaded = Boolean(cachedEntry?.details)') - expect(loadedSection).not.toContain('fetchedAt > 0') - expect(resultSection).toContain('} else if (result === null) {') - expect(resultSection).toContain('error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE') - expect(resultSection).toContain('details: result') - }) - - it('routes file viewed mutations through the PR source context', () => { - const source = componentSource('PullRequestPage.tsx') - const helperSection = sourceBetween( - source, - 'function setPRFileViewedForRepo', - 'function PRViewedCheckbox' - ) - const changeSection = sourceBetween( - source, - 'const handlePRFileViewedChange = useCallback', - 'const ownerRepo = parseOwnerRepoFromItemUrl' - ) - - expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)') - expect(helperSection).toContain("'github.setPRFileViewed'") - expect(helperSection).toContain('repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)') - expect(helperSection).toContain('sourceContext: args.sourceContext') - expect(helperSection).toContain('{ local: false }') - expect(changeSection).toContain('canUseDetailsRepoContext') - expect(changeSection).toContain('repoPath: repoPath ??') - expect(changeSection).toContain('sourceContext,') - }) - - it('routes comment mutations through runtime source context when needed', () => { - const source = componentSource('PullRequestPage.tsx') - const helperSection = sourceBetween( - source, - 'function addIssueCommentForRepo', - 'function setPRFileViewedForRepo' - ) - const fileCommentSection = sourceBetween( - source, - 'const handleAddLineComment = useCallback', - 'const renderViewedCheckbox = useCallback' - ) - const conversationSection = sourceBetween( - source, - 'const handleReply = useCallback', - 'const rightPanel =' - ) - const composerSection = sourceBetween( - source, - 'const handleSubmit = useCallback', - 'const canSubmitComment =' - ) - - expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)') - expect(helperSection).toContain("'github.addIssueComment'") - expect(helperSection).toContain("'github.addPRReviewComment'") - expect(helperSection).toContain("'github.addPRReviewCommentReply'") - expect(helperSection).toContain('repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)') - expect(helperSection).toContain('sourceContext: args.sourceContext') - expect(helperSection).toContain('notifyWorkItemDetailsMutation(') - expect(helperSection).toContain('{ local: false }') - expect(fileCommentSection).toContain('sourceContext,') - expect(conversationSection).toContain('sourceContext,') - expect(composerSection).toContain('sourceContext,') - }) - - it('routes PR file contents and runtime viewed invalidations through the PR source context', () => { - const source = componentSource('PullRequestPage.tsx') - const fileContentsSection = sourceBetween( - source, - 'function loadPRFileContents', - 'function setPRFileViewedForRepo' - ) - const fileContentsCacheKeySection = sourceBetween( - source, - 'function getPRFileContentCacheKey', - 'function loadPRFileContents' - ) - const listenerSection = sourceBetween(source, 'let workItemMutatedUnsub', '// Why: bounded LRU') - const commentContextSection = sourceBetween( - source, - 'function CommentCodeContext', - 'const resolvedContextExpansionState' - ) - - expect(fileContentsCacheKeySection).toContain( - 'source:${getTaskSourceCacheScope(args.sourceContext)}' - ) - expect(fileContentsSection).toContain("'github.prFileContents'") - expect(fileContentsSection).toContain( - 'repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)' - ) - expect(fileContentsSection).toContain('sourceContext: args.sourceContext') - expect(fileContentsSection).toContain('sourceContext,') - expect(listenerSection).toContain('onGitHubWorkItemDetailsCacheMutation') - expect(source).toContain('emitGitHubWorkItemDetailsCacheMutation(args)') - expect(source).toContain('options.local !== false') - expect(source).toContain('notifyWorkItemMutated({') - expect(commentContextSection).toContain('sourceContext?: TaskSourceContext | null') - expect(commentContextSection).toMatch( - /loadPRFileContents\(\{\s*repoPath,\s*repoId,\s*sourceContext,\s*prNumber,\s*prRepo,/ - ) - }) - - it('routes check actions through the PR source context', () => { - const source = componentSource('PullRequestPage.tsx') - const checksSection = sourceBetween(source, 'function ChecksTab', 'function MentionTextarea') - - expect(checksSection).toContain('sourceContext?: TaskSourceContext | null') - expect(checksSection).toContain('sourceContext,') - expect(checksSection).toContain("'github.prChecks'") - expect(checksSection).toContain("'github.rerunPRChecks'") - expect(checksSection).toContain("'github.prCheckDetails'") - expect(checksSection).toContain( - 'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)' - ) - expect(checksSection).toContain('window.api.gh.prChecks({') - expect(checksSection).toContain('window.api.gh.rerunPRChecks({') - expect(checksSection).toContain('prCheckDetails({') - }) - - it('routes edit metadata and mutations through the PR source context', () => { - const source = componentSource('PullRequestPage.tsx') - const editHelperSection = sourceBetween( - source, - 'function getGitHubMutationSettings', - 'function GHCommentComposer' - ) - const editSection = sourceBetween( - source, - 'function GHEditSection', - 'function GHCommentComposer' - ) - - expect(editHelperSection).toContain("'github.updateIssue'") - expect(editHelperSection).toContain("'github.updatePRState'") - expect(editHelperSection).toContain("'github.project.updateIssueBySlug'") - expect(editHelperSection).toContain("'github.project.updatePullRequestBySlug'") - expect(editHelperSection).toContain('host: githubProjectHost(args.projectOrigin.host)') - expect(editHelperSection).toContain('host: githubProjectHost(targetSlug.host)') - expect(editHelperSection).toContain('sourceContext?: TaskSourceContext | null') - expect(editHelperSection).toContain("args.sourceContext?.provider === 'github'") - expect(editHelperSection).toContain('getTaskSourceRuntimeSettings(args.sourceContext)') - expect(editHelperSection).toContain( - 'getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext)' - ) - expect(editHelperSection).toContain( - "repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? '')" - ) - expect(editHelperSection).toContain('{ local: false }') - expect(editSection).toContain('getTaskSourceRuntimeSettings(sourceContext)') - expect(editSection).toContain('sourceContext,') - }) - - it('routes merge actions through the repo owner host (#6957)', () => { - const source = componentSource('PullRequestPage.tsx') - const actionsSection = sourceBetween( - source, - 'function PRActionsPanel', - 'function CommentReactions' - ) - - expect(actionsSection).toContain( - 'getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)' - ) - expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)') - expect(actionsSection).toContain( - 'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ===' - ) - expect(actionsSection).toContain("'github.mergePR'") - expect(actionsSection).toContain("'github.setPRAutoMerge'") - expect(actionsSection).toContain('const prRepo = resolvePullRequestRepo(item, projectOrigin)') - expect(actionsSection).not.toContain('prRepo: item.prRepo ?? null') - expect(actionsSection).toContain( - 'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)' - ) - expect(actionsSection).toContain('sourceContext,') - expect(actionsSection).toContain('notifyWorkItemDetailsMutation(') - expect(actionsSection).toContain('{ local: false }') - }) -}) diff --git a/src/renderer/src/components/settings/appearance-interface-summary.test.ts b/src/renderer/src/components/settings/appearance-interface-summary.test.ts new file mode 100644 index 00000000000..e60f1bcae04 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-interface-summary.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { resolveInterfaceSectionSummary } from './appearance-interface-summary' + +describe('resolveInterfaceSectionSummary', () => { + it('includes theme, language, and font when the language setting is shown', () => { + const settings = { + ...getDefaultSettings('/tmp'), + theme: 'dark' as const, + uiLanguage: 'zh' as const, + appFontFamily: 'Inter' + } + + expect(resolveInterfaceSectionSummary(settings)).toBe('Dark · 中文(简体) · Inter') + }) + + it('falls back to the default font label when app font is empty', () => { + const settings = { + ...getDefaultSettings('/tmp'), + theme: 'light' as const, + uiLanguage: 'system' as const, + appFontFamily: '' + } + + expect(resolveInterfaceSectionSummary(settings)).toBe('Light · System · Default font') + }) +}) diff --git a/src/renderer/src/components/settings/appearance-interface-summary.ts b/src/renderer/src/components/settings/appearance-interface-summary.ts new file mode 100644 index 00000000000..e37a4f2aa66 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-interface-summary.ts @@ -0,0 +1,35 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { + getUiLanguageChoiceLabel, + SHOW_UI_LANGUAGE_SETTING, + UI_LANGUAGE_CHOICES +} from '@/i18n/supported-languages' +import { translate } from '@/i18n/i18n' + +function resolveThemeSummary(theme: GlobalSettings['theme']): string { + if (theme === 'system') { + return translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System') + } + if (theme === 'light') { + return translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light') + } + return translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark') +} + +function resolveLanguageSummary(uiLanguage: GlobalSettings['uiLanguage']): string { + const choice = UI_LANGUAGE_CHOICES.find((entry) => entry.value === uiLanguage) + if (choice == null) { + return translate('settings.appearance.language.system', 'System') + } + return getUiLanguageChoiceLabel(choice, translate) +} + +export function resolveInterfaceSectionSummary(settings: GlobalSettings): string { + const fontSummary = + settings.appFontFamily || + translate('auto.components.settings.AppearancePane.interfaceDefaultFont', 'Default font') + if (!SHOW_UI_LANGUAGE_SETTING) { + return `${resolveThemeSummary(settings.theme)} · ${fontSummary}` + } + return `${resolveThemeSummary(settings.theme)} · ${resolveLanguageSummary(settings.uiLanguage)} · ${fontSummary}` +} diff --git a/src/renderer/src/components/settings/developer-permissions-search.test.ts b/src/renderer/src/components/settings/developer-permissions-search.test.ts new file mode 100644 index 00000000000..65e40b193eb --- /dev/null +++ b/src/renderer/src/components/settings/developer-permissions-search.test.ts @@ -0,0 +1,63 @@ +import { afterAll, describe, expect, it } from 'vitest' + +import { i18n } from '@/i18n/i18n' +import { getDeveloperPermissionsPaneSearchEntries } from './developer-permissions-search' +import { matchesSettingsSearch } from './settings-search' + +// Strings macOS 26 System Settings itself shows for the Local Network privacy +// toggle (SecurityPrivacyExtension.appex Localizable.loctable, key LOCAL_NETWORK). +const MACOS_LOCAL_NETWORK_LABEL = { + en: 'local network', + es: 'red local', + ja: 'ローカルネットワーク', + ko: '로컬 네트워크', + zh: '本地网络' +} as const + +function getLanEntry() { + const entry = getDeveloperPermissionsPaneSearchEntries().find((candidate) => + candidate.keywords?.includes('usb') + ) + if (!entry) { + throw new Error('LAN, USB, and Bluetooth search entry is missing') + } + return entry +} + +async function searchInLocale(locale: string, query: string): Promise { + await i18n.changeLanguage(locale) + return matchesSettingsSearch(query, getLanEntry()) +} + +describe('developer permissions LAN search', () => { + afterAll(async () => { + await i18n.changeLanguage('en') + }) + + it.each(Object.entries(MACOS_LOCAL_NETWORK_LABEL))( + 'finds the LAN row from the macOS Local Network wording in %s', + async (locale, label) => { + expect(await searchInLocale(locale, label)).toBe(true) + } + ) + + it('keeps the English LAN aliases findable in every locale', async () => { + for (const locale of Object.keys(MACOS_LOCAL_NETWORK_LABEL)) { + for (const query of ['lan', 'local network', 'local-network']) { + expect(await searchInLocale(locale, query)).toBe(true) + } + } + }) + + it('keeps the localized LAN wording findable', async () => { + expect(await searchInLocale('zh', '局域网')).toBe(true) + expect(await searchInLocale('ja', 'LAN')).toBe(true) + expect(await searchInLocale('ko', 'LAN')).toBe(true) + expect(await searchInLocale('es', 'LAN')).toBe(true) + }) + + it('indexes both wordings as distinct Chinese keywords', async () => { + await i18n.changeLanguage('zh') + expect(getLanEntry().keywords).toEqual(expect.arrayContaining(['局域网', '本地网络'])) + }) +}) diff --git a/src/renderer/src/components/settings/developer-permissions-search.ts b/src/renderer/src/components/settings/developer-permissions-search.ts index 9efad130221..7718bbdebd8 100644 --- a/src/renderer/src/components/settings/developer-permissions-search.ts +++ b/src/renderer/src/components/settings/developer-permissions-search.ts @@ -154,11 +154,16 @@ export const getDeveloperPermissionsPaneSearchEntries = createLocalizedCatalog(( ), keywords: [ // Why: UI says LAN (aligned with mobile), but macOS System Settings still - // labels the privacy toggle "Local Network" — keep both findable. + // labels the privacy toggle "Local Network" — index both through the + // catalogs so every locale keeps the wording macOS actually shows it. + ...translateSearchKeyword( + 'auto.components.settings.developer.permissions.search.87620e6416', + 'lan' + ), ...translateSearchKeyword( 'auto.components.settings.developer.permissions.search.fa3239cd42', - 'lan', - { aliases: ['local network', 'local-network'] } + 'local network', + { aliases: ['local-network'] } ), ...translateSearchKeyword( 'auto.components.settings.developer.permissions.search.c4a4a02ea4', diff --git a/src/renderer/src/components/settings/osc52-clipboard-copy.test.ts b/src/renderer/src/components/settings/osc52-clipboard-copy.test.ts new file mode 100644 index 00000000000..2940148b50e --- /dev/null +++ b/src/renderer/src/components/settings/osc52-clipboard-copy.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import en from '@/i18n/locales/en.json' +import es from '@/i18n/locales/es.json' +import ja from '@/i18n/locales/ja.json' +import ko from '@/i18n/locales/ko.json' +import zh from '@/i18n/locales/zh.json' + +// Why assert the catalog rather than the component: en.json is bundled as the `en` +// resource, so a catalog value wins over translate()'s code fallback. Editing a fallback +// alone renders nothing, and nothing else in CI compares the two. +describe('OSC 52 setting copy', () => { + const locales = { en, es, ja, ko, zh } + + for (const [name, locale] of Object.entries(locales)) { + it(`names Zellij and Grok in both ${name} OSC 52 setting descriptions`, () => { + const pane = locale.auto.components.settings.TerminalPane + for (const copy of [pane['69c64a479c'], pane['6e6480a7df']]) { + expect(copy).toContain('Zellij') + expect(copy).toContain('Grok') + } + }) + } + + // The notice is a one-shot the user sees exactly once, so a stale catalog value would + // ship unnoticed. Pin en.json to the code fallbacks verbatim. + it('keeps the migration notice catalog identical to its code fallbacks', () => { + const notice = en.auto.components.terminal.pane.osc52.clipboard.default.on.notice + expect(notice.title).toBe('TUI clipboard writes are now on by default') + expect(notice.description).toBe( + 'Zellij, tmux, Neovim and other terminal programs can now copy to your clipboard. Turn it off in Terminal settings.' + ) + expect(notice.action).toBe('Open Setting') + }) + + for (const [name, locale] of Object.entries({ es, ja, ko, zh })) { + it(`translates the ${name} migration notice rather than leaving English placeholders`, () => { + const notice = locale.auto.components.terminal.pane.osc52.clipboard.default.on.notice + const english = en.auto.components.terminal.pane.osc52.clipboard.default.on.notice + expect(notice.title).not.toBe(english.title) + expect(notice.description).not.toBe(english.description) + // Product names must survive translation — they are how the user recognizes the change. + expect(notice.description).toContain('Zellij') + }) + } +}) diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index dcfce654488..e5c0af65a92 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -30,6 +30,8 @@ import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import type { GitStatusResult, Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import { formatHostedReviewLabel } from './hosted-review-label' import type { WorkspaceSpaceItem, WorkspaceSpaceWorktree @@ -140,7 +142,15 @@ type WorkspaceDecisionInputs = { remoteStatusesByWorktree: Record hostedReviewCache: Record< string, - { data?: { number: number; state: string; status: string; title: string } | null } + { + data?: { + provider: HostedReviewProvider + number: number + state: string + status: string + title: string + } | null + } > issueCache: Record linearIssueCache: Record< @@ -156,10 +166,6 @@ function pluralize(count: number, singular: string, plural = `${singular}s`): st return `${count} ${count === 1 ? singular : plural}` } -function formatReviewState(state: string): string { - return state.charAt(0).toUpperCase() + state.slice(1) -} - function countLiveTerminals( tabs: readonly TerminalTab[], ptyIdsByTabId: Record @@ -214,9 +220,7 @@ export function getWorkspaceDecisionDetails( const linkedPR = workspaceRecord?.linkedPR ?? null const reviewLabel = hostedReview !== undefined && hostedReview !== null - ? `PR #${hostedReview.number} ${formatReviewState(hostedReview.state)}${ - hostedReview.status && hostedReview.status !== 'none' ? `, ${hostedReview.status}` : '' - }` + ? formatHostedReviewLabel(hostedReview) : linkedPR ? `PR #${linkedPR}` : null diff --git a/src/renderer/src/components/status-bar/hosted-review-label.test.ts b/src/renderer/src/components/status-bar/hosted-review-label.test.ts new file mode 100644 index 00000000000..2152314d125 --- /dev/null +++ b/src/renderer/src/components/status-bar/hosted-review-label.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { formatHostedReviewLabel, formatReviewState } from './hosted-review-label' + +describe('formatReviewState', () => { + it('capitalizes the first letter', () => { + expect(formatReviewState('open')).toBe('Open') + expect(formatReviewState('merged')).toBe('Merged') + }) +}) + +describe('formatHostedReviewLabel', () => { + it('labels a GitLab review as "MR !N" — not "PR #N"', () => { + expect( + formatHostedReviewLabel({ provider: 'gitlab', number: 5, state: 'open', status: 'none' }) + ).toBe('MR !5 Open') + }) + + it('labels a GitHub review as "PR #N"', () => { + expect( + formatHostedReviewLabel({ provider: 'github', number: 42, state: 'open', status: 'none' }) + ).toBe('PR #42 Open') + }) + + it.each([ + ['azure-devops' as const, 'PR #7 Open'], + ['gitea' as const, 'PR #7 Open'] + ])('uses the "PR #" form and correct copy for %s', (provider, expected) => { + expect(formatHostedReviewLabel({ provider, number: 7, state: 'open', status: 'none' })).toBe( + expected + ) + }) + + it('appends a non-"none" status suffix', () => { + expect( + formatHostedReviewLabel({ provider: 'gitlab', number: 5, state: 'open', status: 'draft' }) + ).toBe('MR !5 Open, draft') + }) + + it('omits the suffix when status is "none" or empty', () => { + expect( + formatHostedReviewLabel({ provider: 'github', number: 5, state: 'closed', status: '' }) + ).toBe('PR #5 Closed') + }) +}) diff --git a/src/renderer/src/components/status-bar/hosted-review-label.ts b/src/renderer/src/components/status-bar/hosted-review-label.ts new file mode 100644 index 00000000000..a0ad57008a9 --- /dev/null +++ b/src/renderer/src/components/status-bar/hosted-review-label.ts @@ -0,0 +1,25 @@ +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import { + localizedHostedReviewCopy, + resolveSupportedHostedReviewCopyProvider +} from '@/i18n/hosted-review-localized-copy' + +export function formatReviewState(state: string): string { + return state.charAt(0).toUpperCase() + state.slice(1) +} + +// Why: a hosted review is not always a GitHub PR — GitLab uses "MR !5", not +// "PR #5". Use the provider-aware copy/symbol the rest of the app already uses. +export function formatHostedReviewLabel(review: { + provider: HostedReviewProvider + number: number + state: string + status: string +}): string { + const { shortLabel } = localizedHostedReviewCopy( + resolveSupportedHostedReviewCopyProvider(review.provider) + ) + const symbol = review.provider === 'gitlab' ? '!' : '#' + const statusSuffix = review.status && review.status !== 'none' ? `, ${review.status}` : '' + return `${shortLabel} ${symbol}${review.number} ${formatReviewState(review.state)}${statusSuffix}` +} diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts index 185cc6460e6..4fa8229bcf9 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts @@ -74,13 +74,31 @@ describe('showOsc52ClipboardBlockedToast', () => { expect(toastInfoMock).toHaveBeenCalledTimes(1) }) - it('mentions Grok in every supported locale', () => { + it('keeps the session notice unspent when the toast throws', async () => { + // Why: with the latch above toast.info, a throw burns the opted-out user's one + // hint and every later blocked write fails silently. Only a throwing first call + // separates the two orderings — the passing case satisfies both. + const { showOsc52ClipboardBlockedToast } = await importToastModule() + toastInfoMock.mockImplementationOnce(() => { + throw new Error('toast unavailable') + }) + + expect(() => showOsc52ClipboardBlockedToast()).toThrow('toast unavailable') + showOsc52ClipboardBlockedToast() + + expect(toastInfoMock).toHaveBeenCalledTimes(2) + }) + + it('mentions Grok and Zellij in every supported locale', () => { + // Why assert the catalog, not the code fallback: en.json is bundled as the + // `en` resource, so a catalog value silently wins over translate()'s fallback. const locales = [en, es, ja, ko, zh] for (const locale of locales) { - expect( + const description = locale.auto.components.terminal.pane.osc52.clipboard.blocked.toast['7cf51f74fd'] - ).toContain('Grok') + expect(description).toContain('Grok') + expect(description).toContain('Zellij') } }) }) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts index fb0b5d73a60..fad6a58a6fd 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts @@ -9,7 +9,6 @@ export function showOsc52ClipboardBlockedToast(): void { if (hasShownOsc52ClipboardBlockedToast) { return } - hasShownOsc52ClipboardBlockedToast = true toast.info( translate( @@ -19,7 +18,7 @@ export function showOsc52ClipboardBlockedToast(): void { { description: translate( 'auto.components.terminal.pane.osc52.clipboard.blocked.toast.7cf51f74fd', - 'Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.' + 'Enable TUI clipboard writes in Terminal settings to copy from SSH, Zellij, tmux, Neovim, fzf, or Grok.' ), duration: 12_000, action: { @@ -42,4 +41,7 @@ export function showOsc52ClipboardBlockedToast(): void { } } ) + // Why latch after: a throw above would otherwise burn the session's one notice + // without ever showing it, leaving the opted-out user with silent failures. + hasShownOsc52ClipboardBlockedToast = true } diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts new file mode 100644 index 00000000000..67bdc0d0099 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor' +import { + shouldShowOsc52ClipboardDefaultOnNotice, + useOsc52ClipboardDefaultOnNotice +} from './osc52-clipboard-default-on-notice' + +const { toastInfoMock, storeState } = vi.hoisted(() => ({ + toastInfoMock: vi.fn(), + storeState: { + osc52ClipboardDefaultOnNoticePending: true, + clearOsc52ClipboardDefaultOnNotice: vi.fn(), + setSettingsSearchQuery: vi.fn(), + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn() + } +})) + +vi.mock('sonner', () => ({ toast: { info: toastInfoMock } })) + +vi.mock('@/store', () => { + const useAppStore = (selector: (state: typeof storeState) => T): T => selector(storeState) + useAppStore.getState = (): typeof storeState => storeState + return { useAppStore } +}) + +const mountedRoots: Root[] = [] + +function HookProbe({ persistedUIReady }: { persistedUIReady: boolean }): null { + useOsc52ClipboardDefaultOnNotice(persistedUIReady) + return null +} + +async function mountProbe(persistedUIReady: boolean): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + await act(async () => { + root.render(createElement(HookProbe, { persistedUIReady })) + }) +} + +describe('shouldShowOsc52ClipboardDefaultOnNotice', () => { + it('shows once the migrating profile has hydrated', () => { + expect( + shouldShowOsc52ClipboardDefaultOnNotice({ persistedUIReady: true, noticePending: true }) + ).toBe(true) + }) + + it('stays quiet before hydration, when the flag still reads its default', () => { + // Why: pre-hydration the store holds `false` for everyone, so firing on that + // value would nag every profile — including the ones never opted out. + expect( + shouldShowOsc52ClipboardDefaultOnNotice({ persistedUIReady: false, noticePending: true }) + ).toBe(false) + expect( + shouldShowOsc52ClipboardDefaultOnNotice({ persistedUIReady: false, noticePending: false }) + ).toBe(false) + }) + + it('stays quiet for a profile the migration did not override', () => { + expect( + shouldShowOsc52ClipboardDefaultOnNotice({ persistedUIReady: true, noticePending: false }) + ).toBe(false) + }) +}) + +describe('useOsc52ClipboardDefaultOnNotice', () => { + beforeEach(() => { + toastInfoMock.mockReset() + storeState.osc52ClipboardDefaultOnNoticePending = true + storeState.clearOsc52ClipboardDefaultOnNotice.mockReset() + storeState.setSettingsSearchQuery.mockReset() + storeState.openSettingsTarget.mockReset() + storeState.openSettingsPage.mockReset() + }) + + afterEach(() => { + for (const root of mountedRoots.splice(0)) { + act(() => root.unmount()) + } + document.body.innerHTML = '' + }) + + it('toasts the armed profile without spending the notice up front', async () => { + // Why not consume on enqueue: quitting inside the toast's 15s would burn the profile's + // only notice on a launch where the posture change was never actually communicated. + await mountProbe(true) + + expect(toastInfoMock).toHaveBeenCalledTimes(1) + expect(storeState.clearOsc52ClipboardDefaultOnNotice).not.toHaveBeenCalled() + }) + + it.each(['onAutoClose', 'onDismiss'] as const)('consumes the notice on %s', async (callback) => { + await mountProbe(true) + + // Why assert the option exists before invoking: optional chaining makes a missing + // callback a silent no-op, so a revert to clear-at-enqueue would satisfy the count + // below without ever wiring this close path. + const close = toastInfoMock.mock.calls[0]?.[1]?.[callback] + expect(close).toBeTypeOf('function') + expect(storeState.clearOsc52ClipboardDefaultOnNotice).not.toHaveBeenCalled() + + close() + + expect(storeState.clearOsc52ClipboardDefaultOnNotice).toHaveBeenCalledTimes(1) + }) + + it('dedupes the StrictMode double-invoke with a stable toast id', async () => { + // Why pin the id: the effect re-runs against the same closure, so the early return + // cannot catch the second pass — sonner collapses it only because the id matches. + await mountProbe(true) + + expect(toastInfoMock.mock.calls[0]?.[1]?.id).toBe('osc52-clipboard-default-on-notice') + }) + + it('keeps the notice armed when the toast throws, so it is not burned unshown', async () => { + toastInfoMock.mockImplementationOnce(() => { + throw new Error('toast unavailable') + }) + + await expect(mountProbe(true)).rejects.toThrow('toast unavailable') + expect(storeState.clearOsc52ClipboardDefaultOnNotice).not.toHaveBeenCalled() + }) + + it('stays silent for a disarmed profile and before hydration', async () => { + storeState.osc52ClipboardDefaultOnNoticePending = false + await mountProbe(true) + expect(toastInfoMock).not.toHaveBeenCalled() + + storeState.osc52ClipboardDefaultOnNoticePending = true + await mountProbe(false) + expect(toastInfoMock).not.toHaveBeenCalled() + expect(storeState.clearOsc52ClipboardDefaultOnNotice).not.toHaveBeenCalled() + }) + + it('deep-links to the OSC 52 terminal setting', async () => { + await mountProbe(true) + + const options = toastInfoMock.mock.calls[0]?.[1] + expect(options.description).toContain('Zellij') + expect(options.action.label).toBe('Open Setting') + + // Why assert unspent first: without it, a revert to clear-at-enqueue satisfies the + // count below and the action's own clear becomes deletable with nothing going red. + expect(storeState.clearOsc52ClipboardDefaultOnNotice).not.toHaveBeenCalled() + + options.action.onClick() + + // Why assert the clear here: sonner's action path deletes the toast without firing + // onDismiss, so acting on the notice would otherwise leave it to re-toast next launch. + expect(storeState.clearOsc52ClipboardDefaultOnNotice).toHaveBeenCalledTimes(1) + expect(storeState.setSettingsSearchQuery).toHaveBeenCalledWith('') + expect(storeState.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'terminal', + repoId: null, + sectionId: OSC52_CLIPBOARD_SETTING_ID + }) + expect(storeState.openSettingsPage).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.ts new file mode 100644 index 00000000000..901e67b62c2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.ts @@ -0,0 +1,68 @@ +import { useEffect } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor' + +/** Whether the migrating launch should tell the user their OSC 52 opt-out was overridden. */ +export function shouldShowOsc52ClipboardDefaultOnNotice(input: { + persistedUIReady: boolean + noticePending: boolean +}): boolean { + // Why gate on hydration: before ui.get() lands the flag reads false, and firing + // on the default would nag every profile that was never opted out. + return input.persistedUIReady && input.noticePending +} + +export function useOsc52ClipboardDefaultOnNotice(persistedUIReady: boolean): void { + const noticePending = useAppStore((s) => s.osc52ClipboardDefaultOnNoticePending) + const clearNotice = useAppStore((s) => s.clearOsc52ClipboardDefaultOnNotice) + + useEffect(() => { + if (!shouldShowOsc52ClipboardDefaultOnNotice({ persistedUIReady, noticePending })) { + return + } + toast.info( + translate( + 'auto.components.terminal.pane.osc52.clipboard.default.on.notice.title', + 'TUI clipboard writes are now on by default' + ), + { + // Why a stable id: StrictMode re-runs this effect against the same closure, so + // the early return can't catch the second pass — sonner dedupes it instead. + id: 'osc52-clipboard-default-on-notice', + description: translate( + 'auto.components.terminal.pane.osc52.clipboard.default.on.notice.description', + 'Zellij, tmux, Neovim and other terminal programs can now copy to your clipboard. Turn it off in Terminal settings.' + ), + duration: 15_000, + // Why consume on close rather than on enqueue: this is the profile's one notice that + // a security posture changed under it, and the flag can never re-arm once the settings + // stamp lands. Clearing at enqueue spends it on launches where nothing was ever seen — + // a quit inside the 15s, a crash in the gap. Clearing on close costs at most a repeat + // next launch, which the user ends by dismissing it. + onAutoClose: clearNotice, + onDismiss: clearNotice, + action: { + label: translate( + 'auto.components.terminal.pane.osc52.clipboard.default.on.notice.action', + 'Open Setting' + ), + onClick: () => { + // Why clear here too: sonner's action path deletes the toast without firing + // onDismiss, so acting on the notice would otherwise leave it armed. + clearNotice() + const store = useAppStore.getState() + store.setSettingsSearchQuery('') + store.openSettingsTarget({ + pane: 'terminal', + repoId: null, + sectionId: OSC52_CLIPBOARD_SETTING_ID + }) + store.openSettingsPage() + } + } + } + ) + }, [clearNotice, noticePending, persistedUIReady]) +} diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts index c714dc61f40..8c1dada1643 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { handleOsc52ClipboardRequest, parseOsc52 } from './osc52-clipboard' +import { + createOsc52OscHandler, + handleOsc52ClipboardRequest, + parseOsc52, + resolveOsc52ClipboardGate +} from './osc52-clipboard' function b64(s: string): string { return Buffer.from(s, 'utf-8').toString('base64') @@ -51,11 +56,26 @@ describe('parseOsc52', () => { expect(parseOsc52(b64('no-semicolon'))).toMatchObject({ kind: 'invalid' }) }) - it('rejects empty selection list', () => { - // Why: the spec defaults to "s0" on empty, but treating malformed - // payloads as clipboard writes would let buggy/malicious emitters - // mutate the clipboard unintentionally. - expect(parseOsc52(`;${b64('x')}`)).toMatchObject({ kind: 'invalid' }) + it('treats an empty selection list as clipboard, the way tmux emits it', () => { + // Why: tmux copies via `\e]52;;` with no selection letter, so + // rejecting empty Pc broke tmux copy. Zellij always sends an explicit 'c'/'p'. + expect(parseOsc52(`;${b64('from tmux')}`)).toEqual({ + kind: 'write', + selections: 'c', + text: 'from tmux' + }) + }) + + it('still refuses to answer a query when Pc is empty', () => { + expect(parseOsc52(';?')).toEqual({ kind: 'query' }) + }) + + it('rejects an empty payload instead of blanking the clipboard', () => { + // Why: this is XTerm's "clear the selection", which we decline to honor — with + // the gate default-on, any PTY could otherwise blank the clipboard for free. + expect(parseOsc52(';')).toMatchObject({ kind: 'invalid' }) + expect(parseOsc52('c;')).toMatchObject({ kind: 'invalid' }) + expect(parseOsc52('c; ')).toMatchObject({ kind: 'invalid' }) }) it('rejects unknown selection letters', () => { @@ -102,6 +122,41 @@ describe('handleOsc52ClipboardRequest', () => { expect(onBlockedWrite).toHaveBeenCalledTimes(1) }) + it('never answers a clipboard query even with writes enabled', () => { + // A guard, not coverage of this change: queries were already refused. It matters + // more now only because default-on makes `allowClipboardWrite: true` the norm. + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue(undefined) + + for (const query of ['c;?', ';?']) { + expect( + handleOsc52ClipboardRequest(query, { allowClipboardWrite: true, writeClipboardText }) + ).toBe(true) + } + + expect(writeClipboardText).not.toHaveBeenCalled() + }) + + it('routes every selection kind to the system clipboard, including bare PRIMARY', () => { + // Pins today's behavior rather than endorsing it: `selections` is parsed but never + // consulted, so a `p`-only write lands in CLIPBOARD. Routing it to the PRIMARY sink + // on Linux is a live question — this test is what makes that a deliberate break. + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue(undefined) + + for (const selections of ['p', 'q', 's0', 'pc']) { + handleOsc52ClipboardRequest(`${selections};${b64(`via ${selections}`)}`, { + allowClipboardWrite: true, + writeClipboardText + }) + } + + expect(writeClipboardText.mock.calls.map(([text]) => text)).toEqual([ + 'via p', + 'via q', + 'via s0', + 'via pc' + ]) + }) + it('does not surface blocked queries because Orca must not answer them', () => { const onBlockedWrite = vi.fn() @@ -114,3 +169,209 @@ describe('handleOsc52ClipboardRequest', () => { expect(onBlockedWrite).not.toHaveBeenCalled() }) }) + +describe('createOsc52OscHandler', () => { + function setup( + overrides: { + settingEnabled?: boolean | null + replaying?: boolean + writeClipboardText?: ReturnType Promise>> + } = {} + ) { + const settingEnabled = 'settingEnabled' in overrides ? overrides.settingEnabled : true + const writeClipboardText = + overrides.writeClipboardText ?? + vi.fn<(text: string) => Promise>().mockResolvedValue(undefined) + const showBlockedWriteToast = vi.fn() + const handler = createOsc52OscHandler({ + getSettingEnabled: () => settingEnabled, + getReplaying: () => overrides.replaying ?? false, + writeClipboardText, + showBlockedWriteToast + }) + return { handler, writeClipboardText, showBlockedWriteToast } + } + + it('writes through to the clipboard for a live pane', async () => { + const { handler, writeClipboardText } = setup() + expect(handler(`c;${b64('live copy')}`)).toBe(true) + await Promise.resolve() + expect(writeClipboardText).toHaveBeenCalledWith('live copy') + }) + + it('coalesces a flood of writes into one clipboard call', async () => { + // Why: a 15-byte sequence repeated across one hostile chunk would otherwise fire + // a million IPC round-trips and native clipboard writes. Last write still wins. + const { handler, writeClipboardText } = setup() + for (let i = 0; i < 1000; i++) { + handler(`c;${b64(`copy ${i}`)}`) + } + await Promise.resolve() + expect(writeClipboardText).toHaveBeenCalledExactlyOnceWith('copy 999') + }) + + it('does not coalesce across separate turns', async () => { + const { handler, writeClipboardText } = setup() + handler(`c;${b64('first')}`) + await Promise.resolve() + handler(`c;${b64('second')}`) + await Promise.resolve() + expect(writeClipboardText).toHaveBeenNthCalledWith(1, 'first') + expect(writeClipboardText).toHaveBeenNthCalledWith(2, 'second') + }) + + it('reads the gate inputs at fire time so a mid-session toggle applies', async () => { + // Why getters, not values: settings hydrate and toggle after the handler is registered. + let enabled = false + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue(undefined) + const handler = createOsc52OscHandler({ + getSettingEnabled: () => enabled, + getReplaying: () => false, + writeClipboardText, + showBlockedWriteToast: vi.fn() + }) + + handler(`c;${b64('before')}`) + await Promise.resolve() + expect(writeClipboardText).not.toHaveBeenCalled() + + enabled = true + handler(`c;${b64('after')}`) + await Promise.resolve() + expect(writeClipboardText).toHaveBeenCalledExactlyOnceWith('after') + }) + + // The coalesced write runs in a microtask, outside the parser handler xterm guards, so + // a bridge failure there escapes the pane as an uncaught error instead of a failed copy. + it.each([ + [ + // Dropping the try/catch turns this into an uncaught exception that fails the run. + 'a clipboard bridge that throws synchronously', + vi.fn<(text: string) => Promise>(() => { + throw new Error('clipboard unavailable') + }) + ], + [ + // Not revert-proof on its own — dropping the `?.` turns this into a TypeError the + // try/catch also swallows. It pins the behavior: a bad bridge must not crash the pane. + 'a preload whose bridge returns nothing', + vi.fn<(text: string) => Promise>(() => undefined as unknown as Promise) + ] + ])('survives %s', async (_label, writeClipboardText) => { + const { handler } = setup({ writeClipboardText }) + + expect(handler(`c;${b64('copy me')}`)).toBe(true) + await Promise.resolve() + await Promise.resolve() + + expect(writeClipboardText).toHaveBeenCalledWith('copy me') + }) + + it('swallows a rejected clipboard write rather than leaking an unhandled rejection', async () => { + // Why the listener: an unhandled rejection here does not fail this suite on its own, + // so without it the `.catch` on the coalesced write is deletable with nothing going red. + const unhandled: unknown[] = [] + const record = (reason: unknown): void => { + unhandled.push(reason) + } + process.on('unhandledRejection', record) + const written: string[] = [] + try { + // Why not vi.fn here: the spy tracks settled results, which marks the rejection + // handled and hides exactly the leak this test exists to catch. + const handler = createOsc52OscHandler({ + getSettingEnabled: () => true, + getReplaying: () => false, + writeClipboardText: (text) => { + written.push(text) + return Promise.reject(new Error('denied by OS')) + }, + showBlockedWriteToast: vi.fn() + }) + + expect(handler(`c;${b64('copy me')}`)).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 10)) + } finally { + process.off('unhandledRejection', record) + } + + expect(written).toEqual(['copy me']) + expect(unhandled).toEqual([]) + }) + + it('keeps coalescing after a failed write instead of wedging the pane', async () => { + // Why: a stranded flush latch silently kills clipboard copy for the rest of the session. + // Today two things prevent it — the latch resets before the write, and the try/catch means + // the microtask reaches its end either way — so this binds the pair, not the ordering alone. + const writeClipboardText = vi + .fn<(text: string) => Promise>() + .mockImplementationOnce(() => { + throw new Error('clipboard unavailable') + }) + .mockResolvedValue(undefined) + const { handler } = setup({ writeClipboardText }) + + handler(`c;${b64('first')}`) + await Promise.resolve() + handler(`c;${b64('second')}`) + await Promise.resolve() + + expect(writeClipboardText).toHaveBeenNthCalledWith(2, 'second') + }) + + it('drops a replayed write and stays silent about it', async () => { + const { handler, writeClipboardText, showBlockedWriteToast } = setup({ replaying: true }) + expect(handler(`c;${b64('stale scrollback copy')}`)).toBe(true) + await Promise.resolve() + expect(writeClipboardText).not.toHaveBeenCalled() + expect(showBlockedWriteToast).not.toHaveBeenCalled() + }) + + it('toasts only for a real opt-out, never for unhydrated settings', async () => { + const optedOut = setup({ settingEnabled: false }) + optedOut.handler(`c;${b64('blocked')}`) + expect(optedOut.showBlockedWriteToast).toHaveBeenCalledTimes(1) + + const unhydrated = setup({ settingEnabled: null }) + unhydrated.handler(`c;${b64('blocked')}`) + await Promise.resolve() + expect(unhydrated.writeClipboardText).not.toHaveBeenCalled() + expect(unhydrated.showBlockedWriteToast).not.toHaveBeenCalled() + }) +}) + +describe('resolveOsc52ClipboardGate', () => { + it('allows a live write once the setting is on', () => { + expect(resolveOsc52ClipboardGate({ settingEnabled: true, replaying: false })).toEqual({ + allowClipboardWrite: true, + shouldSurfaceBlockedWrite: false + }) + }) + + it('drops replayed writes so restore cannot clobber the clipboard', () => { + // Why: reattach re-writes recorded PTY bytes through the same parser, so an old + // copy would silently overwrite what the user has copied since (#10588). + expect(resolveOsc52ClipboardGate({ settingEnabled: true, replaying: true })).toEqual({ + allowClipboardWrite: false, + shouldSurfaceBlockedWrite: false + }) + }) + + it('surfaces the blocked toast only for a real opt-out', () => { + expect(resolveOsc52ClipboardGate({ settingEnabled: false, replaying: false })).toEqual({ + allowClipboardWrite: false, + shouldSurfaceBlockedWrite: true + }) + }) + + it('stays quiet when a write races settings hydration', () => { + // Why: the toast latches once per renderer session; an unhydrated read looks + // blocked even though the default is on, so burning it there hides the real one. + for (const settingEnabled of [null, undefined]) { + expect(resolveOsc52ClipboardGate({ settingEnabled, replaying: false })).toEqual({ + allowClipboardWrite: false, + shouldSurfaceBlockedWrite: false + }) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard.ts index bcde6890212..a2b074f2e84 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard.ts @@ -1,23 +1,37 @@ // OSC 52 — "Manipulate Selection Data". xterm.js does not implement this -// handler itself; applications register it to let TUIs (tmux, neovim, fzf, -// ripgrep) copy to the host clipboard over SSH or through the PTY. +// handler itself; applications register it to let TUIs (Zellij, tmux, Neovim, +// fzf, Grok) copy to the host clipboard over SSH or through the PTY. // // Wire format (xterm.js strips the leading `\x1b]52;` and trailing BEL/ST // before handing us the payload string): // // Pc ; Pd // -// Pc is one or more selection-kind letters ("c"=clipboard, "p"=primary, -// "q"=secondary, "s"=select); Pd is base64-encoded UTF-8. If Pd is "?" the -// TUI is *querying* the clipboard — we deliberately ignore that case to -// avoid leaking clipboard contents to any process writing to the PTY. +// Pc is zero or more selection-kind letters ("c"=clipboard, "p"=primary, +// "q"=secondary, "s"=select). Every kind lands in the system clipboard and +// `selections` does not route the write — longstanding behavior this flip +// only makes reachable by default. A PRIMARY sink exists +// (writeSelectionClipboardText), so routing `p` there on Linux is an open +// question, deliberately left out of a change that is about the gate. +// Pd is base64-encoded UTF-8. If Pd is "?" the TUI is *querying* the +// clipboard — we deliberately ignore that case to avoid leaking clipboard +// contents to any process writing to the PTY. // -// Safety: OSC 52 is a classic data-exfil / overwrite vector — piping an -// attacker-controlled log into the terminal could silently replace the -// user's clipboard. Callers must gate on the user-opt-in setting -// `terminalAllowOsc52Clipboard` before invoking the handler. +// Safety: OSC 52 is a classic clipboard-overwrite vector — piping an +// attacker-controlled log into the terminal could silently replace the user's +// clipboard. Callers gate on `terminalAllowOsc52Clipboard` (default on; query +// stays blocked, so nothing is exfiltrated; payload size is capped). +// +// Accepted residual risk of default-on: the decoded text is written verbatim, +// newlines and all, so a hostile PTY can stage an execute-on-paste payload. +// We do not filter here — a multi-line copy out of a TUI is the feature — and +// the mitigation belongs at paste time, where bracketed paste keeps a pasted +// newline out of the shell's input (see terminal-bracketed-paste.ts). This +// matches kitty and Ghostty, which both allow OSC 52 writes unprompted by +// default while still gating reads. export type Osc52ParseResult = + /** `selections` is normalized: an empty Pc is reported as 'c'. */ | { kind: 'write'; selections: string; text: string } | { kind: 'query' } | { kind: 'invalid'; reason: string } @@ -28,7 +42,88 @@ export type Osc52ClipboardRequestOptions = { onBlockedWrite?: () => void } -const MAX_OSC52_BYTES = 128 * 1024 +const MAX_OSC52_BASE64_CHARS = 128 * 1024 + +/** Resolves whether an incoming OSC 52 write may touch the clipboard, and whether a + * refusal is worth telling the user about. */ +export function resolveOsc52ClipboardGate(input: { + /** Null/undefined until settings hydrate. */ + settingEnabled: boolean | null | undefined + /** True while recorded PTY bytes are being written back into this pane. */ + replaying: boolean +}): { allowClipboardWrite: boolean; shouldSurfaceBlockedWrite: boolean } { + // Why drop during replay: reattach and cold-restore re-write recorded PTY bytes through the same + // parser, so a stale `\e]52;c;…` would overwrite whatever the user has copied since. No fresh intent. + // + // Known over-suppression: the flag is read when xterm parses, not when the bytes were queued, and + // the replay path drains queued live bytes before engaging the guard (pty-connection.ts, "drain any + // queued background bytes BEFORE the replay paint"). A copy issued in the same tick as a reattach + // is therefore dropped silently. Fixing it means tagging chunks at queue time; a lost copy the user + // can repeat is the cheaper side of that trade. + const allowClipboardWrite = !input.replaying && input.settingEnabled === true + return { + allowClipboardWrite, + // Why not toast on replay or pre-hydration: the toast latches once per renderer session, and neither + // case is a real opt-out — unhydrated settings read as blocked even though the default is on. + shouldSurfaceBlockedWrite: + !allowClipboardWrite && + !input.replaying && + input.settingEnabled !== null && + input.settingEnabled !== undefined + } +} + +/** Composes the gate with the request handler into an xterm OSC handler. + * Extracted so the wiring is covered too, not just the gate in isolation. */ +export function createOsc52OscHandler(deps: { + getSettingEnabled: () => boolean | null | undefined + getReplaying: () => boolean + writeClipboardText: (text: string) => Promise + showBlockedWriteToast: () => void +}): (data: string) => boolean { + // Why coalesce: each sequence is only ~15 bytes, so one hostile chunk can fire a + // million parser callbacks — each a main-process clipboard write. Only the last of + // a microtask's worth is observable, so keep that and drop the rest. This bounds a + // flood to roughly one write per xterm parse yield, not to one write overall. + let pendingText: string | null = null + let flushScheduled = false + const writeCoalesced = (text: string): Promise => { + pendingText = text + if (!flushScheduled) { + flushScheduled = true + queueMicrotask(() => { + flushScheduled = false + const next = pendingText + pendingText = null + if (next !== null) { + // Why try/catch and not just .catch(): the write moved out of the guarded + // parser handler into a microtask, where a sync throw (or a preload that + // never installed writeClipboardText) would surface as an uncaught error. + try { + void deps.writeClipboardText(next)?.catch(() => { + /* ignore clipboard write failures */ + }) + } catch { + /* ignore clipboard write failures */ + } + } + }) + } + return Promise.resolve() + } + + return (data) => { + const gate = resolveOsc52ClipboardGate({ + settingEnabled: deps.getSettingEnabled(), + replaying: deps.getReplaying() + }) + return handleOsc52ClipboardRequest(data, { + allowClipboardWrite: gate.allowClipboardWrite, + writeClipboardText: writeCoalesced, + onBlockedWrite: gate.shouldSurfaceBlockedWrite ? deps.showBlockedWriteToast : undefined + }) + } +} export function handleOsc52ClipboardRequest( data: string, @@ -55,16 +150,12 @@ export function parseOsc52(data: string): Osc52ParseResult { if (semi === -1) { return { kind: 'invalid', reason: 'missing selection/data separator' } } - const selections = data.slice(0, semi) + // Why accept empty Pc: tmux copies via `\e]52;;` (window-copy.c passes an + // empty clip through the `Ms` capability). XTerm would read that as `s0`; we merge + // every kind into the clipboard regardless (see header). Zellij always sends `c`/`p`. + const selections = data.slice(0, semi) || 'c' const payload = data.slice(semi + 1) - // Why reject empty selections: the spec allows it (defaults to "s0"), but - // every TUI we care about emits at least one letter, and treating empty - // as "apply to clipboard" would let malformed payloads mutate the - // clipboard by accident. - if (selections.length === 0) { - return { kind: 'invalid', reason: 'empty selection list' } - } if (!/^[cpqs0-7]+$/.test(selections)) { return { kind: 'invalid', reason: 'unknown selection kind' } } @@ -76,7 +167,7 @@ export function parseOsc52(data: string): Osc52ParseResult { // Why guard size: xterm's own parser caps OSC payloads at ~10 MB; we cap // tighter because a legitimate clipboard write is rarely more than a // screenful and any multi-MB payload is almost certainly a bug or abuse. - if (payload.length > MAX_OSC52_BYTES) { + if (payload.length > MAX_OSC52_BASE64_CHARS) { return { kind: 'invalid', reason: 'payload exceeds size limit' } } @@ -84,6 +175,12 @@ export function parseOsc52(data: string): Osc52ParseResult { if (decoded === null) { return { kind: 'invalid', reason: 'payload is not valid base64' } } + // Why reject empty: this is XTerm's "clear the selection", which we decline to + // honor — with the gate default-on, any PTY could blank the clipboard for free. + // (A truncated sequence never lands here; xterm only calls us on parse success.) + if (decoded === '') { + return { kind: 'invalid', reason: 'empty payload' } + } return { kind: 'write', selections, text: decoded } } diff --git a/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts b/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts new file mode 100644 index 00000000000..2484b853474 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + arePaneTitleOverlayRectsEqual, + clearPaneTitleOverlayRects +} from './pane-title-overlay-rects' + +describe('clearPaneTitleOverlayRects', () => { + // Why: this identity contract IS the React #185 guard — a fresh {} literal is + // never state-equal, so returning one here commits a render every pass. + it('returns the same reference when already empty', () => { + const prev = {} + expect(clearPaneTitleOverlayRects(prev)).toBe(prev) + }) + + it('clears a populated map to a new empty object', () => { + const prev = { 1: { left: 10, top: 20, width: 30 } } + const next = clearPaneTitleOverlayRects(prev) + + expect(next).not.toBe(prev) + expect(next).toEqual({}) + }) + + it('is stable across repeated calls once empty', () => { + const first = clearPaneTitleOverlayRects({ 1: { left: 1, top: 2, width: 3 } }) + expect(clearPaneTitleOverlayRects(first)).toBe(first) + }) +}) + +describe('arePaneTitleOverlayRectsEqual', () => { + it('treats sub-pixel drift as equal', () => { + expect( + arePaneTitleOverlayRectsEqual( + { 1: { left: 10, top: 20, width: 30 } }, + { 1: { left: 10.4, top: 20.4, width: 30.4 } } + ) + ).toBe(true) + }) + + it('treats a half-pixel-or-greater move as different', () => { + expect( + arePaneTitleOverlayRectsEqual( + { 1: { left: 10, top: 20, width: 30 } }, + { 1: { left: 10.5, top: 20, width: 30 } } + ) + ).toBe(false) + }) + + it('treats differing pane counts as different', () => { + expect( + arePaneTitleOverlayRectsEqual( + { 1: { left: 0, top: 0, width: 10 } }, + { 1: { left: 0, top: 0, width: 10 }, 2: { left: 0, top: 0, width: 10 } } + ) + ).toBe(false) + }) + + it('reports two empty maps as equal', () => { + expect(arePaneTitleOverlayRectsEqual({}, {})).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.ts b/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.ts new file mode 100644 index 00000000000..f03bc8f3503 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.ts @@ -0,0 +1,33 @@ +/** + * Pane-title overlay rect state transitions for TerminalPane. + * + * Why: both helpers exist to hand React a referentially-equal value when + * nothing changed, so a measurement pass cannot commit a redundant render + * (React #185 amplification). Extracted so that identity contract is testable. + */ +import type { PaneTitleOverlayRect } from './TerminalPaneHeaderOverlay' + +export function arePaneTitleOverlayRectsEqual( + a: Record, + b: Record +): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) { + return false + } + return aKeys.every((key) => { + const paneId = Number(key) + const left = Math.abs((a[paneId]?.left ?? 0) - (b[paneId]?.left ?? 0)) + const top = Math.abs((a[paneId]?.top ?? 0) - (b[paneId]?.top ?? 0)) + const width = Math.abs((a[paneId]?.width ?? 0) - (b[paneId]?.width ?? 0)) + return left < 0.5 && top < 0.5 && width < 0.5 + }) +} + +/** Returns `prev` untouched when already empty — never a fresh {} literal. */ +export function clearPaneTitleOverlayRects( + prev: Record +): Record { + return Object.keys(prev).length === 0 ? prev : {} +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index cd03a7669cb..329519946a6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -245,7 +245,6 @@ type MockTransport = { impl: (opts: { callbacks?: ConnectCallbacks } & Record) => Promise ) => unknown } - disconnect: ReturnType sendInput: ReturnType sendInputImmediate: ReturnType sendInputAccepted?: ReturnType @@ -378,9 +377,6 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport } return ptyId }), - disconnect: vi.fn(() => { - ptyId = null - }), sendInput: vi.fn(() => true), claimViewport: vi.fn(() => true), resize: vi.fn(() => true), @@ -4777,6 +4773,56 @@ describe('connectPanePty', () => { expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) + it('recovers a wedged write pipeline after accepted input without renderer output', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const { settleTerminalWriteStallWatch, WRITE_PIPELINE_STALL_CHECK_MS } = + await import('@/lib/pane-manager/terminal-write-pipeline-health') + const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) + mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState + const transport = createMockTransport('pty-wedged') + transportFactoryQueue.push(transport) + const pane = createPane(1) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + pane.terminal.write.mockClear() + pane.terminal.write.mockImplementation(() => {}) + + sendTerminalInputThroughPane(pane, 'x') + settleTerminalWriteStallWatch(pane.terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) + await flushAsyncTicks() + + expect(transport.sendInput).toHaveBeenCalledWith('x') + expect(pane.terminal.write).toHaveBeenCalledWith('', expect.any(Function)) + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1') + binding.dispose() + }) + + it('does not arm the wedge probe when acknowledged input is rejected', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const { WRITE_PIPELINE_STALL_CHECK_MS } = + await import('@/lib/pane-manager/terminal-write-pipeline-health') + const transport = createMockTransport('pty-rejected') + transport.sendInputAccepted = vi.fn().mockResolvedValue(false) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + pane.terminal.write.mockClear() + pane.terminal.write.mockImplementation(() => {}) + + sendTerminalInputThroughPane(pane, '\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) + await flushAsyncTicks() + + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03') + expect(pane.terminal.write).not.toHaveBeenCalledWith('', expect.any(Function)) + binding.dispose() + }) + it('blocks input when tab-level ptyId is stale even if panePtyId is null', async () => { const { connectPanePty } = await import('./pty-connection') @@ -6426,213 +6472,6 @@ describe('connectPanePty', () => { } }) - it('re-runs the resume command when a hibernated local session reattaches with no payload', async () => { - // Why: the daemon drops startup commands on reattach, so a passive hibernation record must replace a contentless adopted shell. - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout - - try { - const { connectPanePty } = await import('./pty-connection') - const paneKey = makePaneKey('tab-1', LEAF_2) - let activePtyId: string | null = 'restored-session' - const transport = createMockTransport('restored-session') - transport.getPtyId.mockImplementation(() => activePtyId) - transport.disconnect.mockImplementation(() => { - activePtyId = null - }) - transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { - if (opts.sessionId) { - activePtyId = opts.sessionId - return { - id: opts.sessionId, - isReattach: true, - snapshot: undefined, - replay: undefined, - coldRestore: undefined - } - } - activePtyId = 'fresh-resume-pty' - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - onPtySpawn?.('fresh-resume-pty') - return 'fresh-resume-pty' - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'restored-session' }] }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - // Mirrors the user's scenario: a stopped/completed agent that hibernated. - state: 'done', - origin: 'worktree-sleep', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } - }) - vi.mocked(window.api.pty.declarePendingPaneSerializer) - .mockResolvedValueOnce(1) - .mockResolvedValueOnce(2) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - for (const fn of pendingTimeouts) { - fn() - } - await flushAsyncTicks(10) - - expect(transport.disconnect).toHaveBeenCalledTimes(1) - expect(transport.connect).toHaveBeenCalledTimes(2) - expect(transport.connect).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", - env: expect.objectContaining({ - ORCA_PANE_KEY: paneKey, - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) - }) - }) - ) - // The dead session is not adopted as the pane's live PTY. - expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') - expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'restored-session') - expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(2, 'restored-session') - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'fresh-resume-pty') - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) - expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(paneKey, 1) - } finally { - globalThis.setTimeout = originalSetTimeout - } - }) - - it('keeps a contentless reattach when the sleeping record represents a live session', async () => { - const { connectPanePty } = await import('./pty-connection') - const paneKey = makePaneKey('tab-1', LEAF_2) - const transport = createMockTransport('restored-session') - transport.connect.mockResolvedValue({ - id: 'restored-session', - isReattach: true, - snapshot: undefined, - replay: undefined, - coldRestore: undefined - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'restored-session' }] }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - origin: 'live', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(transport.disconnect).not.toHaveBeenCalled() - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - }) - - it('keeps a contentless reattach when a live status supersedes passive sleep evidence', async () => { - const { connectPanePty } = await import('./pty-connection') - const paneKey = makePaneKey('tab-1', LEAF_2) - const transport = createMockTransport('restored-session') - transport.connect.mockResolvedValue({ - id: 'restored-session', - isReattach: true, - snapshot: undefined, - replay: undefined, - coldRestore: undefined - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'restored-session' }] }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - agentStatusByPaneKey: { - [paneKey]: { - state: 'working', - prompt: 'new live task', - agentType: 'codex', - providerSession: { key: 'session_id', id: 'live-codex-session' }, - paneKey, - updatedAt: 2, - stateStartedAt: 2, - stateHistory: [] - } - }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'old-codex-session' }, - prompt: 'old completed task', - state: 'done', - origin: 'worktree-sleep', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(transport.disconnect).not.toHaveBeenCalled() - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - }) - it('clears the pending serializer when disposed before non-deferred SSH reattach expiry resolves', async () => { const { connectPanePty } = await import('./pty-connection') const reattach = createDeferred() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 90c7aa09781..736b73a6f01 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -76,7 +76,8 @@ import { } from './replay-guard' import { isTerminalWritePipelineCertifiedDead, - registerUndeliverableWriteHandler + registerUndeliverableWriteHandler, + requestTerminalWritePipelineProbe } from '@/lib/pane-manager/terminal-write-pipeline-health' import { captureTerminalPaneRecoveryGeneration, @@ -3357,6 +3358,8 @@ export function connectPanePty( const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 const markTerminalInputSent = (): void => { lastTerminalInputAt = performance.now() + // Why: input must probe a wedged xterm even when the PTY produces no renderer output. + requestTerminalWritePipelineProbe(pane.terminal) } const recordTerminalInputForHibernation = (): void => { useAppStore.getState().recordTerminalInput(cacheKey) @@ -3728,12 +3731,12 @@ export function connectPanePty( cancelSuspendedShellCommandInference() } clearPendingTerminalInputIntent() - markTerminalInputSent() const writePromise = transport .sendInputAccepted(data) .then((accepted) => { if (accepted) { - recordTerminalInputForHibernationFallback() + // Why: rejected writes use transport recovery and must not arm a parser probe. + markAcceptedTerminalInputSent() observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, acknowledgedIntent) interruptInference.observeInputIntent(acknowledgedIntent) @@ -4561,9 +4564,6 @@ export function connectPanePty( ? launchConfig.agentEnv : resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv), ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), - ...(launchConfig?.ompResumeFilePath - ? { ompResumeFilePath: launchConfig.ompResumeFilePath } - : {}), platform: resumePlatform }) if (!startupPlan) { @@ -7294,30 +7294,6 @@ export function connectPanePty( if (!isCurrentReattachPayload()) { return false } - // Strict precedence snapshot > replay > coldRestore: paint exactly one, else overlapping tails duplicate TUI output on worktree switch. - const hasStructuralReplay = Boolean( - connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore - ) - const resumeComesFromPassiveHibernation = Boolean( - coldRestoreStartup && - !coldRestoreStartup.useLiveEntry && - coldRestoreStartup.sleepingRecordEntry && - isPassiveCompletedHibernationEvidence(coldRestoreStartup.sleepingRecordEntry.record) - ) - // Why: reattach drops startup commands; only passive hibernation is authority to retire an empty adopted shell and resume its provider session. - if (!hasStructuralReplay && connectResult?.isReattach && resumeComesFromPassiveHibernation) { - transport.disconnect() - if (staleSessionId) { - deps.clearExitedPanePtyLayoutBinding(pane.id, staleSessionId) - deps.clearTabPtyId(deps.tabId, staleSessionId) - } else { - deps.syncPanePtyLayoutBinding(pane.id, null) - } - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return false - } setPanePtyFitBinding(ptyId) reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) registerSideEffectFactConsumerForPty(ptyId) @@ -7330,6 +7306,10 @@ export function connectPanePty( // Why: mobile streaming needs xterm's exact screen state; install the serializer + lastTitle source for main-process hydration parity. registerPaneSerializerFor(ptyId) + // Strict precedence snapshot > replay > coldRestore: paint exactly one, else overlapping tails duplicate TUI output on worktree switch. + const hasStructuralReplay = Boolean( + connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore + ) let reattachPayloadApplied = !hasStructuralReplay const applyReattachPayload = async (): Promise => { if (!isCurrentReattachPayload()) { @@ -7759,9 +7739,7 @@ export function connectPanePty( finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { - if (!accepted) { - await window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } else if (!isRemoteRuntimePtyId(pendingSessionId)) { + if (!isRemoteRuntimePtyId(pendingSessionId)) { const settledPtyId = result && typeof result === 'object' && 'id' in result ? result.id @@ -7972,9 +7950,7 @@ export function connectPanePty( finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { - if (!accepted) { - await window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } else if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { + if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { const settledPtyId = result && typeof result === 'object' && 'id' in result ? result.id diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts index ddf856f05ae..2a5632eab7b 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -7,6 +7,7 @@ import { TERMINAL_INPUT_CHUNK_MAX_BYTES, TERMINAL_INPUT_MAX_BYTES } from '../../../../shared/terminal-input' +import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' const WHEEL_UP_REPORT = '\x1b[<64;60;20M' @@ -154,4 +155,41 @@ describe('pty input write queue', () => { expect(writes.length).toBe(1) expect(writes.map((write) => write.data).join('')).not.toContain('tail') }) + + it('rejects producer backlog beyond the retained item and text budgets', async () => { + const writes: WriteRecord[] = [] + const pendingYields: (() => void)[] = [] + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2) + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }), + yieldBetweenWrites: () => + new Promise((resolve) => { + pendingYields.push(resolve) + }), + maxPendingItems: 2, + maxPendingCodeUnits: large.length + 4 + }) + + expect(queue.enqueue('pty-1', large)).toBe(true) + expect(queue.enqueue('pty-1', 'tail')).toBe(true) + expect(queue.enqueue('pty-1', '!')).toBe(false) + + queue.clear() + pendingYields.shift()?.() + await queue.waitForDrain() + + expect(writes).toEqual([{ id: 'pty-1', data: large.slice(0, TERMINAL_INPUT_CHUNK_MAX_BYTES) }]) + }) + + it('does not write deferred input after clear', async () => { + const { writes, queue } = createRecordingQueue() + const deferred = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + + expect(queue.enqueue('pty-1', deferred)).toBe(true) + queue.clear() + await queue.waitForDrain() + + expect(writes).toEqual([]) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts index b20e764df15..9857a0ff0b9 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -1,4 +1,5 @@ import { + TERMINAL_INPUT_MAX_BYTES, isTerminalInputTooLargeWithDeferredMeasurement, iterateTerminalInputChunks } from '../../../../shared/terminal-input' @@ -7,6 +8,7 @@ import { // 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on // the hot input path. export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 +export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_ITEMS = 4_096 type PendingPtyInputWrite = { id: string @@ -26,6 +28,8 @@ export type PtyInputWriteQueueDeps = { isWritable: (id: string) => boolean write: (id: string, data: string) => void yieldBetweenWrites?: () => Promise + maxPendingItems?: number + maxPendingCodeUnits?: number } function defaultYieldBetweenWrites(): Promise { @@ -38,28 +42,47 @@ function isCoalescibleText(text: string): boolean { export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue { const yieldBetweenWrites = deps.yieldBetweenWrites ?? defaultYieldBetweenWrites + const maxPendingItems = positiveLimit( + deps.maxPendingItems, + PTY_INPUT_WRITE_QUEUE_MAX_PENDING_ITEMS + ) + const maxPendingCodeUnits = positiveLimit(deps.maxPendingCodeUnits, TERMINAL_INPUT_MAX_BYTES) let pending: PendingPtyInputWrite[] = [] + let pendingCodeUnits = 0 let drainPromise: Promise | null = null + let clearVersion = 0 + + function shiftPending(): PendingPtyInputWrite | undefined { + const shifted = pending.shift() + if (shifted) { + pendingCodeUnits -= shifted.text.length + } + return shifted + } async function drain(): Promise { while (pending.length > 0) { const next = pending[0] if (!next) { - pending.shift() + shiftPending() continue } if (!deps.isWritable(next.id)) { - pending.shift() + shiftPending() continue } if (next.tooLarge !== false) { + const validationVersion = clearVersion next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) + if (validationVersion !== clearVersion) { + continue + } if (next.tooLarge) { - pending.shift() + shiftPending() continue } if (!deps.isWritable(next.id)) { - pending.shift() + shiftPending() continue } } @@ -72,7 +95,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput // the PTY byte stream identical while draining the backlog in one turn. if (next.chunks === undefined && isCoalescibleText(next.text)) { let payload = next.text - pending.shift() + shiftPending() while (pending.length > 0) { const peek = pending[0] if ( @@ -86,7 +109,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput break } payload += peek.text - pending.shift() + shiftPending() } deps.write(next.id, payload) if (pending.length > 0) { @@ -99,13 +122,13 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } next.nextChunk = undefined if (chunk.done) { - pending.shift() + shiftPending() continue } deps.write(next.id, chunk.value) const following = next.chunks.next() if (following.done) { - pending.shift() + shiftPending() } else { next.nextChunk = following.value } @@ -134,7 +157,14 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput if (tooLarge === true) { return false } + if ( + pending.length >= maxPendingItems || + pendingCodeUnits + data.length > maxPendingCodeUnits + ) { + return false + } pending.push({ id, text: data, tooLarge }) + pendingCodeUnits += data.length scheduleDrain() return true } catch { @@ -150,6 +180,12 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput clear(): void { pending = [] + pendingCodeUnits = 0 + clearVersion += 1 } } } + +function positiveLimit(value: number | undefined, fallback: number): number { + return Number.isFinite(value) && (value ?? 0) > 0 ? Math.floor(value ?? fallback) : fallback +} diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts index 45846d08143..b77d5c5c1ef 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts @@ -5,6 +5,7 @@ import { drainRolledBackPtyShutdownData, ptyDataHandlers, ptyReplayHandlers, + PTY_SHUTDOWN_OUTPUT_MAX_EVENTS, ptyShutdownLifecycleHandlers, ptyTeardownHandlers, unregisterPtyDataHandlers @@ -74,3 +75,20 @@ it('retains ordered rollback output across detach and another pending shutdown', ptyTeardownHandlers.delete(ptyId) ptyShutdownLifecycleHandlers.delete(ptyId) }) + +it('bounds zero-byte shutdown events independently of buffered text', () => { + const ptyId = 'pty-shutdown-empty-event-flood' + const delivered = vi.fn() + ptyDataHandlers.set(ptyId, delivered) + ptyReplayHandlers.set(ptyId, vi.fn()) + + const [snapshot] = unregisterPtyDataHandlers([ptyId]) + for (let index = 0; index < PTY_SHUTDOWN_OUTPUT_MAX_EVENTS + 10; index += 1) { + expect(bufferPtyShutdownData(ptyId, '')).toBe(true) + } + snapshot.rollback() + + expect(delivered).toHaveBeenCalledTimes(PTY_SHUTDOWN_OUTPUT_MAX_EVENTS) + ptyDataHandlers.delete(ptyId) + ptyReplayHandlers.delete(ptyId) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts index 0bc654bfe8e..9dea97306a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts @@ -40,6 +40,7 @@ type PtyShutdownOutputEvent = const rolledBackShutdownEvents = new Map() const ROLLED_BACK_SHUTDOWN_REPLAY_MAX_PTYS = 64 +export const PTY_SHUTDOWN_OUTPUT_MAX_EVENTS = 4_096 const shutdownBufferTextEncoder = new TextEncoder() /** Suspend delivery until every overlapping shutdown owner commits or rolls back. */ @@ -117,7 +118,8 @@ function bufferPtyShutdownOutput(ptyId: string, event: PtyShutdownOutputEvent): pending.events.push({ ...event, data: clamped.data }) pending.bufferedBytes += clamped.bytes while ( - pending.bufferedBytes > TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT && + (pending.bufferedBytes > TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT || + pending.events.length > PTY_SHUTDOWN_OUTPUT_MAX_EVENTS) && pending.events.length > 1 ) { pending.bufferedBytes -= shutdownBufferTextEncoder.encode( diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 2b835045c74..3359e579d6a 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -50,9 +50,6 @@ export type PtyConnectResult = { /** The requested session exited while it had no primary pane handler. Its * buffered final data/exit were delivered, so callers must not fresh-spawn. */ exitedBeforeAttach?: boolean - /** The provider adopted an existing session rather than creating a fresh one. - * Startup commands may be ignored; recovery still requires separate ownership evidence. */ - isReattach?: boolean launchAgent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig snapshot?: string diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 3df5d04c03d..cedcaac4882 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -100,21 +100,6 @@ describe('createIpcPtyTransport', () => { expect(kill).not.toHaveBeenCalled() }) - it('retires an adopted PTY when recovery disconnects before a replacement spawn', async () => { - const { createIpcPtyTransport } = await import('./pty-transport') - const spawn = window.api.pty.spawn as unknown as ReturnType - const kill = window.api.pty.kill as unknown as ReturnType - spawn.mockResolvedValueOnce({ id: 'empty-reattach', isReattach: true }) - const transport = createIpcPtyTransport({}) - - await transport.connect({ url: '', sessionId: 'empty-reattach', callbacks: {} }) - transport.disconnect() - - expect(kill).toHaveBeenCalledWith('empty-reattach') - expect(transport.getPtyId()).toBeNull() - expect(transport.isConnected()).toBe(false) - }) - it('forwards requested environment deletions to the PTY spawn', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType @@ -1404,7 +1389,6 @@ describe('createIpcPtyTransport', () => { expect(result).toEqual({ id: 'pty-reattach', - isReattach: true, launchAgent: 'droid', snapshot: 'snapshot data', snapshotCols: 132, @@ -1429,7 +1413,6 @@ describe('createIpcPtyTransport', () => { expect(result).toEqual({ id: 'pty-unknown-launch-agent', - isReattach: true, snapshot: undefined, snapshotCols: undefined, snapshotRows: undefined, diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index d3bdd6082ba..3d97f90f124 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -772,8 +772,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (spawnResult.isReattach || spawnResult.coldRestore || spawnResult.sessionExpired) { return { id: spawnResult.id, - // Why: recovery needs to distinguish an attach that ignored startup intent from a fresh spawn that ran it. - ...(spawnResult.isReattach ? { isReattach: true } : {}), ...(resultLaunchAgent ? { launchAgent: resultLaunchAgent } : {}), ...(spawnResult.launchConfig ? { launchConfig: spawnResult.launchConfig } : {}), snapshot: spawnResult.snapshot, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts index d07bd2900af..974ec388eba 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts @@ -119,6 +119,54 @@ describe('createRemoteRuntimePtyTextBatcher', () => { } }) + it('bounds strings retained behind deferred validation without changing accepted order', async () => { + vi.useFakeTimers() + try { + const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + const batcher = createRemoteRuntimePtyTextBatcher(1_000, () => {}, { + maxPendingBytes: text.length + 10, + maxValidationQueuedCodeUnits: text.length + 4, + maxValidationQueuedEntries: 3 + }) + + expect(batcher.push(text)).toBe(true) + expect(batcher.push('tail')).toBe(true) + expect(batcher.push('!')).toBe(false) + + const drained = batcher.drain() + await vi.advanceTimersByTimeAsync(0) + await drained + + expect(batcher.takePending()).toBe(`${text}tail`) + } finally { + vi.useRealTimers() + } + }) + + it('bounds tiny entries retained behind deferred validation', async () => { + vi.useFakeTimers() + try { + const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + const batcher = createRemoteRuntimePtyTextBatcher(1_000, () => {}, { + maxPendingBytes: text.length + 10, + maxValidationQueuedCodeUnits: text.length + 10, + maxValidationQueuedEntries: 2 + }) + + expect(batcher.push(text)).toBe(true) + expect(batcher.push('a')).toBe(true) + expect(batcher.push('b')).toBe(false) + + const drained = batcher.drain() + await vi.advanceTimersByTimeAsync(0) + await drained + + expect(batcher.takePending()).toBe(`${text}a`) + } finally { + vi.useRealTimers() + } + }) + it('drops asynchronously oversized input without flushing clipboard content', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts index 33561b06953..c2d293f934b 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts @@ -24,8 +24,12 @@ export type RemoteRuntimeViewportBatcher = { export type RemoteRuntimePtyTextBatcherOptions = { maxPendingBytes?: number maxBytes?: number + maxValidationQueuedCodeUnits?: number + maxValidationQueuedEntries?: number } +export const REMOTE_RUNTIME_PTY_VALIDATION_QUEUE_MAX_ENTRIES = 4_096 + export function createRemoteRuntimePtyTextBatcher( delayMs: number, onFlush: (text: string) => void, @@ -36,11 +40,21 @@ export function createRemoteRuntimePtyTextBatcher( TERMINAL_INPUT_CHUNK_MAX_BYTES ) const maxBytes = getPositiveByteLimit(options.maxBytes, TERMINAL_INPUT_MAX_BYTES) + const maxValidationQueuedCodeUnits = getPositiveByteLimit( + options.maxValidationQueuedCodeUnits, + maxBytes + ) + const maxValidationQueuedEntries = getPositiveByteLimit( + options.maxValidationQueuedEntries, + REMOTE_RUNTIME_PTY_VALIDATION_QUEUE_MAX_ENTRIES + ) let pending = '' let pendingBytes = 0 let timer: ReturnType | null = null let validationTail: Promise | null = null let validationVersion = 0 + let validationQueuedCodeUnits = 0 + let validationQueuedEntries = 0 const clearTimer = (): void => { if (timer) { @@ -55,6 +69,8 @@ export function createRemoteRuntimePtyTextBatcher( pendingBytes = 0 validationVersion += 1 validationTail = null + validationQueuedCodeUnits = 0 + validationQueuedEntries = 0 } const flush = (): void => { @@ -96,8 +112,16 @@ export function createRemoteRuntimePtyTextBatcher( } } - const enqueueValidatedInput = (data: string, tooLarge: false | Promise): void => { + const enqueueValidatedInput = (data: string, tooLarge: false | Promise): boolean => { + if ( + validationQueuedEntries >= maxValidationQueuedEntries || + validationQueuedCodeUnits + data.length > maxValidationQueuedCodeUnits + ) { + return false + } const queuedVersion = validationVersion + validationQueuedEntries += 1 + validationQueuedCodeUnits += data.length const previousTail = validationTail ?? Promise.resolve() const guardedTail = previousTail.then(async () => { if (validationVersion !== queuedVersion) { @@ -113,11 +137,16 @@ export function createRemoteRuntimePtyTextBatcher( const nextTail = guardedTail .catch(() => {}) .finally(() => { + if (validationVersion === queuedVersion) { + validationQueuedEntries -= 1 + validationQueuedCodeUnits -= data.length + } if (validationTail === nextTail) { validationTail = null } }) validationTail = nextTail + return true } const drain = async (): Promise => { @@ -143,8 +172,7 @@ export function createRemoteRuntimePtyTextBatcher( return true } - enqueueValidatedInput(data, tooLarge) - return true + return enqueueValidatedInput(data, tooLarge) }, // Why: earlier input can be mid async byte-length validation and not yet in // `pending`. `takePending()` cannot see it, so callers that must preserve diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 8a6cc5b611e..340e4f23cd5 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -2251,7 +2251,7 @@ describe('createRemoteRuntimePtyTransport', () => { result: { runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1', 'agent-session.omp-resume-path.v1'] + capabilities: ['agent-session.host-authority.v1'] } } : { ok: true, result: { terminal: { handle: 'terminal-1' } } } @@ -2274,14 +2274,14 @@ describe('createRemoteRuntimePtyTransport', () => { env: { CODEX_PROFILE: 'captured', ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token' }, launchConfig: { agentArgs: '--model gpt-5', - agentEnv: { CODEX_PROFILE: 'captured' }, - ompResumeFilePath: '/custom/omp/project/session.jsonl' + agentEnv: { CODEX_PROFILE: 'captured' } }, launchToken: 'fresh-token', - launchAgent: 'omp', + launchAgent: 'codex', resumeProviderSession: { key: 'session_id', - id: 'session-1' + id: 'session-1', + transcriptPath: '/home/example/.codex/sessions/2026/07/20/rollout-a.jsonl' }, callbacks: {} }) @@ -2293,12 +2293,12 @@ describe('createRemoteRuntimePtyTransport', () => { params: expect.objectContaining({ kind: 'explicit', worktree: 'id:wt-1', - agent: 'omp', + agent: 'codex', providerSession: { key: 'session_id', - id: 'session-1' + id: 'session-1', + transcriptPath: '/home/example/.codex/sessions/2026/07/20/rollout-a.jsonl' }, - ompResumeFilePath: '/custom/omp/project/session.jsonl', agentArgs: '--profile captured', placement: { tabId: 'tab-1', leafId: 'pane:1' }, presentation: 'background' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 7e857588c75..3ca3cb964a6 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -17,10 +17,7 @@ import type { RuntimeTerminalResolvePane, RuntimeTerminalSend } from '../../../../shared/runtime-types' -import { - AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, - TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' +import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { isTerminalInputTooLargeWithDeferredMeasurement, iterateTerminalInputChunks @@ -52,6 +49,7 @@ import { createRemoteRuntimePtyTextBatcher, createRemoteRuntimeViewportBatcher } from './remote-runtime-pty-batching' +import { createRemoteRuntimeViewportClaimInput } from './remote-runtime-viewport-claim-input' import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS, RemoteRuntimePtyRecoveryState @@ -176,7 +174,7 @@ export function createRemoteRuntimePtyTransport( }) let lastRecoveryStateKey = '' let pendingViewportClaim = false - let pendingClaimInput = '' + const pendingClaimInput = createRemoteRuntimeViewportClaimInput() let terminalCreateRetryWait: { timer: ReturnType resolve: (continueRetrying: boolean) => void @@ -199,7 +197,7 @@ export function createRemoteRuntimePtyTransport( const viewportClaimReadyWaiters = new Set<(ready: boolean) => void>() const clearPendingViewportClaim = (): void => { pendingViewportClaim = false - pendingClaimInput = '' + pendingClaimInput.clear() for (const resolve of viewportClaimReadyWaiters) { resolve(false) } @@ -978,7 +976,7 @@ export function createRemoteRuntimePtyTransport( } if (pendingViewportClaim) { // Why: a claim during subscribe/reconnect has no stream record yet; hold its input so the stream emits claim+input in one order. - pendingClaimInput += text + pendingClaimInput.append(text) return } void callRuntime('terminal.send', { @@ -1459,8 +1457,7 @@ export function createRemoteRuntimePtyTransport( if (pendingViewportClaim && desiredViewport) { nextStream.claimViewport(desiredViewport.cols, desiredViewport.rows) pendingViewportClaim = false - const queuedInput = pendingClaimInput - pendingClaimInput = '' + const queuedInput = pendingClaimInput.take() if (queuedInput) { nextStream.sendInput(queuedInput) } @@ -1566,9 +1563,6 @@ export function createRemoteRuntimePtyTransport( worktree: toRuntimeTerminalWorktreeSelector(worktreeId), agent: launchAgentToSend!, providerSession: resumeProviderSessionToSend, - ...(launchConfigToSend?.ompResumeFilePath - ? { ompResumeFilePath: launchConfigToSend.ompResumeFilePath } - : {}), ...(agentArgsOverride !== undefined ? { agentArgs: agentArgsOverride } : {}), ...(agentLaunchPreferences ? { launchPreferences: agentLaunchPreferences } @@ -1609,9 +1603,6 @@ export function createRemoteRuntimePtyTransport( : await runRemoteAgentSessionLaunch({ environmentId: createEnvironmentId, hostAuthority: hostAuthorityCreate, - ...(resumeProviderSessionToSend && launchAgentToSend === 'omp' - ? { hostAuthorityCapability: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY } - : {}), legacy: legacyCreate }) : await legacyCreate() @@ -1871,8 +1862,7 @@ export function createRemoteRuntimePtyTransport( return true } if (pendingViewportClaim) { - pendingClaimInput += text - return true + return pendingClaimInput.append(text) } void callRuntime('terminal.send', { terminal: targetHandle, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.test.ts new file mode 100644 index 00000000000..a098271eeb2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { createRemoteRuntimeViewportClaimInput } from './remote-runtime-viewport-claim-input' + +describe('remote runtime viewport-claim input', () => { + it('preserves accepted input order and resets its byte charge on take', () => { + const input = createRemoteRuntimeViewportClaimInput(4) + + expect(input.append('ab')).toBe(true) + expect(input.append('cd')).toBe(true) + expect(input.take()).toBe('abcd') + expect(input.append('next')).toBe(true) + }) + + it('rejects producer backlog before concatenating beyond the byte budget', () => { + const input = createRemoteRuntimeViewportClaimInput(4) + + expect(input.append('é')).toBe(true) + expect(input.append('ab')).toBe(true) + expect(input.append('!')).toBe(false) + expect(input.take()).toBe('éab') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.ts b/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.ts new file mode 100644 index 00000000000..2ea9332066c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.ts @@ -0,0 +1,31 @@ +import { + getTerminalInputByteLength, + TERMINAL_INPUT_MAX_BYTES +} from '../../../../shared/terminal-input' + +export function createRemoteRuntimeViewportClaimInput(maxBytes = TERMINAL_INPUT_MAX_BYTES) { + let text = '' + let bytes = 0 + + return { + append(value: string): boolean { + const valueBytes = getTerminalInputByteLength(value) + if (bytes + valueBytes > maxBytes) { + return false + } + text += value + bytes += valueBytes + return true + }, + clear(): void { + text = '' + bytes = 0 + }, + take(): string { + const value = text + text = '' + bytes = 0 + return value + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts index 81e4efd6af5..d9d0942fa91 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts @@ -217,6 +217,32 @@ export function collectLeafIdsInOrder(node: TerminalPaneLayoutNode | null | unde return [...collectLeafIdsInOrder(node.first), ...collectLeafIdsInOrder(node.second)] } +export function collectTerminalLayoutLeafIds( + snapshot: TerminalLayoutSnapshot | null | undefined +): string[] { + if (!snapshot) { + return [] + } + const ids = new Set(collectLeafIdsInOrder(snapshot.root)) + if (snapshot.activeLeafId) { + ids.add(snapshot.activeLeafId) + } + if (snapshot.expandedLeafId) { + ids.add(snapshot.expandedLeafId) + } + for (const record of [ + snapshot.ptyIdsByLeafId, + snapshot.buffersByLeafId, + snapshot.scrollbackRefsByLeafId, + snapshot.titlesByLeafId + ]) { + for (const leafId of Object.keys(record ?? {})) { + ids.add(leafId) + } + } + return [...ids] +} + export function resolvePtyBoundActiveLeafId(args: { root: TerminalPaneLayoutNode | null | undefined activeLeafId: string | null | undefined diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 09068a4a1a3..8251dc51310 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -9,10 +9,10 @@ import { getTerminalHtmlFileOpenHint, getTerminalUrlOpenHint, installFilePathLinkClickFallback, - mapTerminalFilePath, isTerminalLinkActivation, openFilePathLinkAtBufferPosition, - openDetectedFilePath + openDetectedFilePath, + TERMINAL_FILE_LINK_PROBE_CONCURRENCY } from './terminal-link-handlers' import { TERMINAL_PATH_EXISTS_CACHE_MAX_ENTRIES } from './terminal-path-exists-cache' import { handleOscLink } from './terminal-osc-link-routing' @@ -65,7 +65,7 @@ vi.mock('@/store', () => ({ })) vi.mock('@/lib/language-detect', () => ({ - detectLanguage: (filePath: string) => (filePath.endsWith('.md') ? 'markdown' : 'plaintext') + detectLanguage: () => 'plaintext' })) // Why: the real helper reads worktreesByRepo/activeRepoId/etc. from the store @@ -111,8 +111,8 @@ beforeEach(() => { vi.mocked(getConnectionId).mockReturnValue(null) openFilePathMock.mockResolvedValue(true) storeState.settings = undefined - storeState.activeFileIdByWorktree = {} storeState.worktreesByRepo = {} + storeState.activeFileIdByWorktree = {} registerHttpLinkStoreAccessor(() => storeState) vi.stubGlobal('window', { dispatchEvent: vi.fn(), @@ -374,8 +374,8 @@ describe('handleOscLink', () => { ) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: '/tmp/src/main.ts', fileId: '/tmp/src/main.ts', + filePath: '/tmp/src/main.ts', line: 42, column: 1, matchLength: 0 @@ -392,8 +392,8 @@ describe('handleOscLink', () => { expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: '/tmp/src/main.ts', fileId: '/tmp/src/main.ts', + filePath: '/tmp/src/main.ts', line: 42, column: 7, matchLength: 0 @@ -401,50 +401,6 @@ describe('handleOscLink', () => { expect(openFilePathMock).not.toHaveBeenCalled() }) - it('opens terminal markdown line links in source mode so Monaco can reveal the line', async () => { - setPlatform('Macintosh') - const filePath = '/tmp/docs/terminal-scroll-intent-architecture.md' - const fileId = 'editor:wt-1:runtime-1:terminal-scroll-intent-architecture.md' - openFileMock.mockImplementationOnce(() => { - storeState.activeFileIdByWorktree['wt-1'] = fileId - }) - - openDetectedFilePath(filePath, 230, null, deps) - await flushAsyncWork() - await flushDoubleRaf() - - expect(setMarkdownViewModeMock).toHaveBeenCalledWith(fileId, 'source') - expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith({ - filePath, - fileId, - line: 230, - column: 1, - matchLength: 0 - }) - }) - - it('scopes non-Markdown line reveals to the owner-qualified editor tab', async () => { - setPlatform('Macintosh') - const filePath = '/tmp/src/main.ts' - const fileId = 'editor:wt-1:runtime-1:main.ts' - openFileMock.mockImplementationOnce(() => { - storeState.activeFileIdByWorktree['wt-1'] = fileId - }) - - openDetectedFilePath(filePath, 42, 7, deps) - await flushAsyncWork() - await flushDoubleRaf() - - expect(setMarkdownViewModeMock).not.toHaveBeenCalled() - expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith({ - filePath, - fileId, - line: 42, - column: 7, - matchLength: 0 - }) - }) - it('uses the system default app for shift+cmd/ctrl-click file paths', async () => { setPlatform('Macintosh') @@ -477,8 +433,8 @@ describe('handleOscLink', () => { ) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: '/tmp/src/main.ts', fileId: '/tmp/src/main.ts', + filePath: '/tmp/src/main.ts', line: 42, column: 7, matchLength: 0 @@ -576,8 +532,8 @@ describe('handleOscLink', () => { ) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: 'C:/repo/src/index.ts', fileId: 'C:/repo/src/index.ts', + filePath: 'C:/repo/src/index.ts', line: 12, column: 3, matchLength: 0 @@ -638,8 +594,8 @@ describe('handleOscLink', () => { ) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: '/tmp/test.txt', fileId: '/tmp/test.txt', + filePath: '/tmp/test.txt', line: 42, column: 1, matchLength: 0 @@ -681,8 +637,8 @@ describe('handleOscLink', () => { ) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: '/tmp/test.txt', fileId: '/tmp/test.txt', + filePath: '/tmp/test.txt', line: 42, column: 7, matchLength: 0 @@ -715,8 +671,8 @@ describe('handleOscLink', () => { ) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: '//server/Share/Repo/src/app.ts', fileId: '//server/Share/Repo/src/app.ts', + filePath: '//server/Share/Repo/src/app.ts', line: 12, column: 3, matchLength: 0 @@ -750,71 +706,6 @@ describe('handleOscLink', () => { expect(openFilePathMock).not.toHaveBeenCalled() }) - it('maps POSIX OSC file links for a WSL worktree before opening them', async () => { - setPlatform('Windows') - - handleOscLink( - '/root/workspace/myrepo/README.md:5:3', - { metaKey: false, ctrlKey: true }, - { - ...deps, - startupCwd: '/root/workspace/myrepo', - worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\repo' - } - ) - await flushAsyncWork() - await flushDoubleRaf() - - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md' - }) - expect(openFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md' - }), - { forceContentReload: true } - ) - expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md', - fileId: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md', - line: 5, - column: 3, - matchLength: 0 - }) - }) - - it('maps file URL OSC links for a WSL worktree before opening them', async () => { - setPlatform('Windows') - - handleOscLink( - 'file:///root/workspace/myrepo/README.md#L5C3', - { metaKey: false, ctrlKey: true }, - { - ...deps, - worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\repo' - } - ) - await flushAsyncWork() - await flushDoubleRaf() - - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md' - }) - expect(openFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md' - }), - { forceContentReload: true } - ) - expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md', - fileId: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md', - line: 5, - column: 3, - matchLength: 0 - }) - }) - it('opens tilde OSC file links against explicit terminal home when cwd is outside home', async () => { setPlatform('Macintosh') @@ -933,54 +824,6 @@ describe('handleOscLink', () => { ) }) - it('pins SSH links outside the worktree to their target host', async () => { - setPlatform('Macintosh') - vi.mocked(getConnectionId).mockReturnValue('ssh-1') - - openDetectedFilePath('/tmp/ssh-preview.png', null, null, { - worktreeId: 'wt-1', - worktreePath: '/home/me/repo' - }) - await flushAsyncWork() - - expect(authorizeExternalPathMock).not.toHaveBeenCalled() - expect(statMock).toHaveBeenCalledWith({ - filePath: '/tmp/ssh-preview.png', - connectionId: 'ssh-1' - }) - expect(openFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: '/tmp/ssh-preview.png', - relativePath: '/tmp/ssh-preview.png', - externalSshTargetId: 'ssh-1' - }), - { forceContentReload: true } - ) - }) - - it('does not pin runtime-owned links to the worktree SSH target', async () => { - setPlatform('Windows') - vi.mocked(getConnectionId).mockReturnValue('ssh-1') - runtimeEnvironmentCallMock.mockResolvedValueOnce({ - id: 'rpc-1', - ok: true, - result: { size: 1, isDirectory: false, mtime: 1 }, - _meta: { runtimeId: 'remote-runtime' } - }) - - openDetectedFilePath('//wsl.localhost/ubuntu/home/Alice/repo/src/main.ts', null, null, { - worktreeId: 'wt-1', - worktreePath: '//wsl$/Ubuntu/home/Alice/repo', - runtimeEnvironmentId: 'env-1' - }) - await flushAsyncWork() - - expect(openFileMock).toHaveBeenCalledWith( - expect.not.objectContaining({ externalSshTargetId: expect.anything() }), - { forceContentReload: true } - ) - }) - it('does not open SSH html file links as client-local file browser tabs', async () => { setPlatform('Macintosh') vi.mocked(getConnectionId).mockReturnValue('ssh-1') @@ -1152,8 +995,8 @@ describe('handleOscLink', () => { ) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: '/tmp/src/second.ts', fileId: '/tmp/src/second.ts', + filePath: '/tmp/src/second.ts', line: 20, column: 3, matchLength: 0 @@ -1235,7 +1078,7 @@ describe('createFilePathLinkProvider range bounds', () => { 1, { worktreeId: 'wt-1', - worktreePath: depsOverrides.worktreePath ?? '/repo', + worktreePath: '/repo', startupCwd: '/repo', managerRef, linkProviderDisposablesRef: { current: new Map() }, @@ -1402,6 +1245,43 @@ describe('createFilePathLinkProvider range bounds', () => { expect(shellPathExists).toHaveBeenCalled() }) + it.each([ + ['at the limit', TERMINAL_FILE_LINK_PROBE_CONCURRENCY], + ['above the limit', TERMINAL_FILE_LINK_PROBE_CONCURRENCY + 1] + ])('bounds path-existence probes %s', async (_, count) => { + let active = 0 + let peak = 0 + const releases: (() => void)[] = [] + const shellPathExists = vi.mocked(window.api.shell.pathExists) + shellPathExists.mockImplementation(async () => { + active++ + peak = Math.max(peak, active) + await new Promise((resolve) => releases.push(resolve)) + active-- + return true + }) + const text = Array.from({ length: count }, (_, index) => `file-${index}.ts`).join(' ') + const { provider } = createProviderSetup([makeBufferLine(text)], new Map()) + const links = new Promise((resolve) => { + provider.provideLinks(1, (provided) => resolve(provided ?? [])) + }) + + expect(shellPathExists).toHaveBeenCalledTimes( + Math.min(count, TERMINAL_FILE_LINK_PROBE_CONCURRENCY) + ) + if (count > TERMINAL_FILE_LINK_PROBE_CONCURRENCY) { + releases.shift()?.() + for (let turn = 0; turn < 5 && shellPathExists.mock.calls.length < count; turn++) { + await Promise.resolve() + } + expect(shellPathExists).toHaveBeenCalledTimes(count) + } + releases.splice(0).forEach((release) => release()) + + await expect(links).resolves.toHaveLength(count) + expect(peak).toBe(Math.min(count, TERMINAL_FILE_LINK_PROBE_CONCURRENCY)) + }) + it('does not invoke the xterm callback twice when the callback throws', async () => { const { provider } = createProviderSetup([makeBufferLine('CLAUDE.md')]) const callback = vi.fn(() => { @@ -1630,45 +1510,6 @@ describe('createFilePathLinkProvider range bounds', () => { expect(openFilePathMock).not.toHaveBeenCalled() }) - it('maps POSIX file paths for a WSL direct-click fallback before opening them', async () => { - setPlatform('Windows') - - const opened = openFilePathLinkAtBufferPosition( - makeBuffer([makeBufferLine('/root/workspace/myrepo/README.md:5:3')]), - { x: 10, y: 1 }, - 80, - { - startupCwd: '/root/workspace/myrepo', - worktreeId: 'wt-1', - worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\repo', - runtimeEnvironmentId: null, - pathExistsCache: new Map([ - ['active\0//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md', true] - ]) - } - ) - await flushAsyncWork() - await flushDoubleRaf() - - expect(opened).toBe(true) - expect(statMock).toHaveBeenCalledWith({ - filePath: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md' - }) - expect(openFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md' - }), - { forceContentReload: true } - ) - expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { - filePath: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md', - fileId: '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md', - line: 5, - column: 3, - matchLength: 0 - }) - }) - it('switches to a known worktree root from direct fallback even when cache says missing', async () => { setPlatform('Macintosh') storeState.worktreesByRepo = { @@ -1846,83 +1687,6 @@ describe('createFilePathLinkProvider range bounds', () => { expect(window.api.shell.pathExists).toHaveBeenCalledWith('/repo/package.json') }) - it.each([ - ['modern', '\\\\wsl.localhost\\Ubuntu\\home\\repo'], - ['legacy', '\\\\wsl$\\Ubuntu\\home\\repo'] - ])('maps POSIX terminal links for a %s WSL worktree', async (_label, worktreePath) => { - const mappedPath = '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md' - vi.mocked(window.api.shell.pathExists).mockImplementation( - async (pathValue) => pathValue === mappedPath - ) - const { provider, linkTooltip } = createProviderSetup( - [makeBufferLine('/root/workspace/myrepo/README.md:5:3')], - new Map(), - { worktreePath, startupCwd: '/root/workspace/myrepo' } - ) - - const links = await new Promise((resolve) => { - provider.provideLinks(1, (provided) => resolve(provided ?? [])) - }) - - expect(links).toHaveLength(1) - expect(window.api.shell.pathExists).toHaveBeenCalledWith(mappedPath) - links[0]!.hover?.({} as MouseEvent, links[0]!.text) - expect(linkTooltip.textContent).toContain(mappedPath) - links[0]!.activate?.( - { ctrlKey: true, metaKey: false, shiftKey: false } as MouseEvent, - links[0]!.text - ) - await flushAsyncWork() - await flushDoubleRaf() - - expect(statMock).toHaveBeenCalledWith({ filePath: mappedPath }) - expect(openFileMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: mappedPath }), { - forceContentReload: true - }) - expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith({ - filePath: mappedPath, - fileId: mappedPath, - line: 5, - column: 3, - matchLength: 0 - }) - }) - - it('resolves relative POSIX terminal links against the pane cwd before mapping', async () => { - const mappedPath = '//wsl.localhost/Ubuntu/root/workspace/myrepo/README.md' - vi.mocked(window.api.shell.pathExists).mockImplementation( - async (pathValue) => pathValue === mappedPath - ) - const { provider } = createProviderSetup([makeBufferLine('README.md:5')], new Map(), { - worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\repo', - startupCwd: '/stale', - getPaneLinkCwd: () => '/root/workspace/myrepo' - }) - - const links = await new Promise((resolve) => { - provider.provideLinks(1, (provided) => resolve(provided ?? [])) - }) - - expect(links).toHaveLength(1) - expect(window.api.shell.pathExists).toHaveBeenCalledWith(mappedPath) - }) - - it('preserves existing UNC and native paths', () => { - expect( - mapTerminalFilePath('//wsl.localhost/Ubuntu/root/file.md', '\\\\wsl.localhost\\Ubuntu\\repo') - ).toBe('//wsl.localhost/Ubuntu/root/file.md') - expect( - mapTerminalFilePath('\\\\server\\share\\file.md', '\\\\wsl.localhost\\Ubuntu\\repo') - ).toBe('\\\\server\\share\\file.md') - expect(mapTerminalFilePath('C:/repo/file.md', '\\\\wsl.localhost\\Ubuntu\\repo')).toBe( - 'C:/repo/file.md' - ) - }) - - it('does not map POSIX paths for a native Windows worktree', () => { - expect(mapTerminalFilePath('/repo/file.md', 'C:\\repo')).toBe('/repo/file.md') - }) - it('opens an existing extensionless spaced prefix from direct fallback cache', async () => { setPlatform('Macintosh') const line = 'see /repo/My Folder now' diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 7ce2c037524..22e570eae82 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -1,4 +1,5 @@ import type { IDisposable, ILink, ILinkProvider, Terminal } from '@xterm/xterm' +import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { extractTerminalFileLinkCandidates, extractTerminalFileLinks, @@ -14,7 +15,6 @@ import { import { getTerminalFileContext, isHtmlFilePath, - mapTerminalFilePath, openDetectedFilePath, shouldOpenTerminalFileWithSystemDefault } from './terminal-file-open-routing' @@ -40,7 +40,6 @@ import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' import { isTerminalLinkActivation } from './terminal-link-activation' export { openDetectedFilePath } from './terminal-file-open-routing' -export { mapTerminalFilePath } from './terminal-file-open-routing' export { openFilePathLinkAtBufferPosition } from './terminal-file-link-hit-testing' export { getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, getTerminalUrlOpenHint } export { isTerminalLinkActivation } from './terminal-link-activation' @@ -58,10 +57,9 @@ export type LinkHandlerDeps = { getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null } -type ProvidedFileLink = { - link: ILink - logicalLine: WrappedLogicalLine -} +type ProvidedFileLink = { link: ILink; logicalLine: WrappedLogicalLine } + +export const TERMINAL_FILE_LINK_PROBE_CONCURRENCY = 8 function rangesOverlap(left: ILink['range'], right: ILink['range']): boolean { const leftStartsAfterRightEnds = @@ -123,97 +121,99 @@ export function createFilePathLinkProvider( return } - void Promise.all( - logicalLines.flatMap((logicalLine) => - extractTerminalFileLinkCandidates(logicalLine.text).map( - async (parsed): Promise => { - const paneLinkCwd = deps.getPaneLinkCwd?.(paneId) ?? startupCwd - const resolved = paneLinkCwd - ? resolveTerminalFileLink(parsed, paneLinkCwd, deps.terminalHomePath) - : null - if (!resolved) { - return null - } - const mappedPath = mapTerminalFilePath(resolved.absolutePath, worktreePath) - const range = rangeForParsedFileLink(logicalLine, parsed.startIndex, parsed.endIndex) - if (!range) { - return null - } + const candidates = logicalLines.flatMap((logicalLine) => + extractTerminalFileLinkCandidates(logicalLine.text).map((parsed) => ({ + logicalLine, + parsed + })) + ) + void mapWithConcurrency( + candidates, + TERMINAL_FILE_LINK_PROBE_CONCURRENCY, + async ({ logicalLine, parsed }): Promise => { + const paneLinkCwd = deps.getPaneLinkCwd?.(paneId) ?? startupCwd + const resolved = paneLinkCwd + ? resolveTerminalFileLink(parsed, paneLinkCwd, deps.terminalHomePath) + : null + if (!resolved) { + return null + } + const range = rangeForParsedFileLink(logicalLine, parsed.startIndex, parsed.endIndex) + if (!range) { + return null + } - const runtimeEnvironmentId = - deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null - const fileContext = getTerminalFileContext( - worktreeId, - worktreePath, - runtimeEnvironmentId - ) - const isRemoteRuntimePath = isRemoteRuntimeFileOperation(fileContext, mappedPath) - const cacheKey = getTerminalPathExistsCacheKey({ - absolutePath: mappedPath, - connectionId: fileContext.connectionId, - isRemoteRuntimePath, - runtimeEnvironmentId - }) - const worktreeRootLink = resolveKnownWorktreeRootPathLink(mappedPath) - if (/[\\/]$/.test(parsed.pathText) && !worktreeRootLink) { - return null - } - // Why: exact known workspace roots must stay clickable for SSH or - // stale local paths even when filesystem probing says "missing". - if (!worktreeRootLink) { - const cachedExists = readTerminalPathExistsCache(pathExistsCache, cacheKey) - const exists = - cachedExists ?? - (fileContext.connectionId || isRemoteRuntimePath - ? await runtimePathExists(fileContext, mappedPath) - : await window.api.shell.pathExists(mappedPath)) - writeTerminalPathExistsCache(pathExistsCache, cacheKey, exists) - if (!exists) { - return null - } - } + const runtimeEnvironmentId = + deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null + const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + const isRemoteRuntimePath = isRemoteRuntimeFileOperation( + fileContext, + resolved.absolutePath + ) + const cacheKey = getTerminalPathExistsCacheKey({ + absolutePath: resolved.absolutePath, + connectionId: fileContext.connectionId, + isRemoteRuntimePath, + runtimeEnvironmentId + }) + const worktreeRootLink = resolveKnownWorktreeRootPathLink(resolved.absolutePath) + if (/[\\/]$/.test(parsed.pathText) && !worktreeRootLink) { + return null + } + // Why: exact known workspace roots must stay clickable for SSH or + // stale local paths even when filesystem probing says "missing". + if (!worktreeRootLink) { + const cachedExists = readTerminalPathExistsCache(pathExistsCache, cacheKey) + const exists = + cachedExists ?? + (fileContext.connectionId || isRemoteRuntimePath + ? await runtimePathExists(fileContext, resolved.absolutePath) + : await window.api.shell.pathExists(resolved.absolutePath)) + writeTerminalPathExistsCache(pathExistsCache, cacheKey, exists) + if (!exists) { + return null + } + } - return { - logicalLine, - link: { - range, - text: parsed.displayText, - activate: (event) => { - if (!isTerminalLinkActivation(event)) { - return - } - openDetectedFilePath(mappedPath, resolved.line, resolved.column, { - worktreeId, - worktreePath, - runtimeEnvironmentId, - openWithSystemDefault: Boolean(event.shiftKey) - }) - }, - hover: () => { - // Why: only local paths can offer the Shift+modifier system - // default escape hatch; remote paths may not exist locally. - const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault( - fileContext, - mappedPath - ) - const hint = worktreeRootLink - ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault) - : canOpenWithSystemDefault - ? isHtmlFilePath(mappedPath) - ? getTerminalHtmlFileOpenHint() - : openLinkHint - : getTerminalOrcaFileOpenHint() - linkTooltip.textContent = `${mappedPath} (${hint})` - linkTooltip.style.display = '' - }, - leave: () => { - linkTooltip.style.display = 'none' - } + return { + logicalLine, + link: { + range, + text: parsed.displayText, + activate: (event) => { + if (!isTerminalLinkActivation(event)) { + return } + openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, { + worktreeId, + worktreePath, + runtimeEnvironmentId, + openWithSystemDefault: Boolean(event.shiftKey) + }) + }, + hover: () => { + // Why: only local paths can offer the Shift+modifier system + // default escape hatch; remote paths may not exist locally. + const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault( + fileContext, + resolved.absolutePath + ) + const hint = worktreeRootLink + ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault) + : canOpenWithSystemDefault + ? isHtmlFilePath(resolved.absolutePath) + ? getTerminalHtmlFileOpenHint() + : openLinkHint + : getTerminalOrcaFileOpenHint() + linkTooltip.textContent = `${resolved.absolutePath} (${hint})` + linkTooltip.style.display = '' + }, + leave: () => { + linkTooltip.style.display = 'none' } } - ) - ) + } + } ) .then( (resolvedLinks) => { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.test.ts new file mode 100644 index 00000000000..d07b613146f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + forgetRetiredTerminalPaneRecovery, + registerTerminalPaneRecoveryRetirementHandler +} from './terminal-pane-recovery-retirement' + +describe('terminal pane recovery retirement bridge', () => { + let unregister: (() => void) | undefined + + afterEach(() => unregister?.()) + + it('routes authoritative retirement without coupling the store to recovery state', () => { + const handler = vi.fn() + unregister = registerTerminalPaneRecoveryRetirementHandler(handler) + + forgetRetiredTerminalPaneRecovery('tab-1') + + expect(handler).toHaveBeenCalledWith('tab-1') + }) + + it('does not retain a retired module handler', () => { + const handler = vi.fn() + unregister = registerTerminalPaneRecoveryRetirementHandler(handler) + unregister() + unregister = undefined + + forgetRetiredTerminalPaneRecovery('tab-1') + + expect(handler).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.ts new file mode 100644 index 00000000000..cf3b6040f33 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.ts @@ -0,0 +1,18 @@ +type TerminalPaneRecoveryRetirementHandler = (tabId: string) => void + +let retirementHandler: TerminalPaneRecoveryRetirementHandler | null = null + +export function registerTerminalPaneRecoveryRetirementHandler( + handler: TerminalPaneRecoveryRetirementHandler +): () => void { + retirementHandler = handler + return () => { + if (retirementHandler === handler) { + retirementHandler = null + } + } +} + +export function forgetRetiredTerminalPaneRecovery(tabId: string): void { + retirementHandler?.(tabId) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts index 7e0486523ef..e9bb49db467 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { _resetTerminalPaneRecoveryForTests, captureTerminalPaneRecoveryGeneration, + forgetTerminalPaneRecovery, registerTerminalPaneRecoveryInstance, requestTerminalPaneRecovery } from './terminal-pane-recovery' @@ -272,6 +273,36 @@ describe('requestTerminalPaneRecovery', () => { healthySuccessor.unregister() }) + it('forgets recovery history and pending work only after authoritative tab retirement', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const instance = registerTerminalPaneRecoveryInstance('tab-1') + await requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'write-stalled', + terminalRecoveryGeneration: captureTerminalPaneRecoveryGeneration('tab-1'), + terminalRecoveryInstanceId: instance.id + }) + const recoveredGeneration = captureTerminalPaneRecoveryGeneration('tab-1') + await requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'replay-wedged', + terminalRecoveryGeneration: recoveredGeneration, + terminalRecoveryInstanceId: instance.id + }) + + expect(recoveredGeneration).toBe(1) + expect(vi.getTimerCount()).toBe(1) + forgetTerminalPaneRecovery('tab-1') + expect(captureTerminalPaneRecoveryGeneration('tab-1')).toBe(0) + expect(vi.getTimerCount()).toBe(0) + + await vi.advanceTimersByTimeAsync(600_000) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(1) + }) + it('keeps a sibling pane retry when the first requesting split is disposed', async () => { vi.useFakeTimers() vi.setSystemTime(0) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts index 791234b232b..1c56b9f17b2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts @@ -1,5 +1,6 @@ import { useAppStore } from '@/store' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { registerTerminalPaneRecoveryRetirementHandler } from './terminal-pane-recovery-retirement' // Why this module exists: a terminal pane can die renderer-side while its PTY // stays alive — a wedged xterm WriteBuffer (issue #2836), a disposed xterm @@ -50,6 +51,7 @@ const RECOVERY_COOLDOWN_MS = 15_000 const recoveryTimestampsByTabId = new Map() const recoveryGenerationByTabId = new Map() const activeTerminalRecoveryInstanceIds = new Set() +const terminalRecoveryTabIdByInstanceId = new Map() const pendingRetryByTabId = new Map< string, { @@ -98,10 +100,12 @@ export function registerTerminalPaneRecoveryInstance(tabId: string): { } { const id = ++nextTerminalRecoveryInstanceId activeTerminalRecoveryInstanceIds.add(id) + terminalRecoveryTabIdByInstanceId.set(id, tabId) return { id, unregister: () => { activeTerminalRecoveryInstanceIds.delete(id) + terminalRecoveryTabIdByInstanceId.delete(id) const pendingRetry = pendingRetryByTabId.get(tabId) pendingRetry?.requestsByInstanceId.delete(id) if (pendingRetry?.requestsByInstanceId.size === 0) { @@ -271,10 +275,30 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro return true } +export function forgetTerminalPaneRecovery(tabId: string): void { + recoveryTimestampsByTabId.delete(tabId) + recoveryGenerationByTabId.delete(tabId) + cancelPendingRecoveryRetry(tabId) + for (const [instanceId, instanceTabId] of terminalRecoveryTabIdByInstanceId) { + if (instanceTabId === tabId) { + terminalRecoveryTabIdByInstanceId.delete(instanceId) + activeTerminalRecoveryInstanceIds.delete(instanceId) + } + } +} + +const unregisterTerminalPaneRecoveryRetirement = registerTerminalPaneRecoveryRetirementHandler( + forgetTerminalPaneRecovery +) +if (import.meta.hot) { + import.meta.hot.dispose(unregisterTerminalPaneRecoveryRetirement) +} + export function _resetTerminalPaneRecoveryForTests(): void { recoveryTimestampsByTabId.clear() recoveryGenerationByTabId.clear() activeTerminalRecoveryInstanceIds.clear() + terminalRecoveryTabIdByInstanceId.clear() nextTerminalRecoveryInstanceId = 0 for (const pendingRetry of pendingRetryByTabId.values()) { clearTimeout(pendingRetry.timer) diff --git a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts new file mode 100644 index 00000000000..8f53bf39c5e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, + TERMINAL_TAB_PARK_FLIP_WINDOW_MS, + recordParkVerdictFlips, + type ParkVerdictFlipRecord +} from './terminal-park-verdict-flip-telemetry' + +const recordBreadcrumb = vi.fn() +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (name: string, data?: unknown) => recordBreadcrumb(name, data) +})) + +const TAB = 'tab-1' + +function observe(args: { + records: Map + parked: boolean + nowMs: number + liveTabIds?: ReadonlySet +}): void { + recordParkVerdictFlips({ + records: args.records, + liveTabIds: args.liveTabIds ?? new Set([TAB]), + nextParkedTabIds: args.parked ? new Set([TAB]) : new Set(), + nowMs: args.nowMs + }) +} + +beforeEach(() => { + recordBreadcrumb.mockClear() +}) + +describe('recordParkVerdictFlips', () => { + it('stays silent for a stable verdict', () => { + const records = new Map() + for (let i = 0; i < 100; i += 1) { + observe({ records, parked: true, nowMs: 1_000 + i * 1_000 }) + } + + expect(recordBreadcrumb).not.toHaveBeenCalled() + expect(records.get(TAB)?.flips).toBe(0) + }) + + it('emits one breadcrumb per window once the verdict churns', () => { + const records = new Map() + for (let i = 0; i < 40; i += 1) { + observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb).toHaveBeenCalledWith( + 'terminal_park_verdict_churn', + expect.objectContaining({ tabId: TAB, flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT }) + ) + }) + + // Why: flips saturates at the notice limit, so elapsedMs is the only field + // that tells a runaway loop apart from slow churn — assert both ends. + it('reports elapsedMs so a tight loop is distinguishable from slow churn', () => { + const tightRecords = new Map() + for (let i = 0; i < 40; i += 1) { + observe({ records: tightRecords, parked: i % 2 === 0, nowMs: 1_000 + i }) + } + expect(recordBreadcrumb).toHaveBeenCalledWith( + 'terminal_park_verdict_churn', + expect.objectContaining({ flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, elapsedMs: 12 }) + ) + + recordBreadcrumb.mockClear() + + const slowRecords = new Map() + for (let i = 0; i < 13; i += 1) { + observe({ records: slowRecords, parked: i % 2 === 0, nowMs: 1_000 + i * 4_000 }) + } + expect(recordBreadcrumb).toHaveBeenCalledWith( + 'terminal_park_verdict_churn', + expect.objectContaining({ flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, elapsedMs: 48_000 }) + ) + }) + + it('re-arms after the window elapses', () => { + const records = new Map() + for (let i = 0; i < 40; i += 1) { + observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) + } + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + + const laterMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2 + for (let i = 0; i < 40; i += 1) { + observe({ records, parked: i % 2 === 0, nowMs: laterMs + i * 10 }) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(2) + }) + + // Why: an unclamped backwards jump would freeze the window and suppress the + // very signal this module exists to capture. + it('treats a backwards clock jump as a fresh window', () => { + const records = new Map() + observe({ records, parked: true, nowMs: 10_000_000 }) + observe({ records, parked: false, nowMs: 1_000 }) + + expect(records.get(TAB)?.windowStartMs).toBe(1_000) + expect(records.get(TAB)?.flips).toBe(1) + }) + + // Why: >= is the boundary operator; a > regression would silently stretch the + // window and delay every notice by one full period. + it('treats an exactly-elapsed window as expired', () => { + const records = new Map() + observe({ records, parked: true, nowMs: 1_000 }) + observe({ records, parked: false, nowMs: 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS }) + + expect(records.get(TAB)?.windowStartMs).toBe(1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS) + expect(records.get(TAB)?.flips).toBe(1) + }) + + it('honours flipWindowMs and noticeLimit overrides', () => { + const records = new Map() + for (let i = 0; i < 10; i += 1) { + recordParkVerdictFlips({ + records, + liveTabIds: new Set([TAB]), + nextParkedTabIds: i % 2 === 0 ? new Set([TAB]) : new Set(), + nowMs: 1_000 + i, + flipWindowMs: 500, + noticeLimit: 3 + }) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb).toHaveBeenCalledWith( + 'terminal_park_verdict_churn', + expect.objectContaining({ flips: 3, windowMs: 500 }) + ) + }) + + it('keeps per-tab windows and notices independent', () => { + const records = new Map() + const other = 'tab-2' + for (let i = 0; i < 40; i += 1) { + recordParkVerdictFlips({ + records, + liveTabIds: new Set([TAB, other]), + // Why: TAB churns every call, other stays parked throughout. + nextParkedTabIds: i % 2 === 0 ? new Set([TAB, other]) : new Set([other]), + nowMs: 1_000 + i * 10 + }) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb).toHaveBeenCalledWith( + 'terminal_park_verdict_churn', + expect.objectContaining({ tabId: TAB }) + ) + expect(records.get(other)?.flips).toBe(0) + }) + + it('drops records for tabs that no longer exist', () => { + const records = new Map() + observe({ records, parked: true, nowMs: 1_000 }) + + recordParkVerdictFlips({ + records, + liveTabIds: new Set(), + nextParkedTabIds: new Set(), + nowMs: 2_000 + }) + + expect(records.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts new file mode 100644 index 00000000000..f54477c90f0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts @@ -0,0 +1,93 @@ +/** + * Cold-park verdict flip telemetry (observation only — changes no verdict). + * + * Why: crash cluster C5 (React #185 in TerminalPaneOverlayLayer) points at a + * setState loop in the cold-parking passive effect, but no park-flip loop has + * been reproduced, and the capture-coverage oscillator that looked like the + * cause provably self-terminates (the unmount capture is never cleared on + * remount, so coverage pins rather than alternates). This records whether the + * rendered park verdict actually churns in the field. Deliberately no damping: + * guarding a loop nobody has shown can run would add a park delay and a + * retention path for no demonstrated benefit. + * + * Reading the signal: breadcrumbs also emit a durable `renderer.breadcrumb` + * trace span, so this is queryable without waiting for a crash bundle. `flips` + * always equals the notice limit — `elapsedMs` is what separates a render loop + * from slow churn. Silence only rules out churn at effect cadence; a same-tick + * cascade can crash before the limit accumulates. If it fires, start with the + * candidate selection in terminal-hidden-view-parking.ts. + */ +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' + +export const TERMINAL_TAB_PARK_FLIP_WINDOW_MS = 60_000 +/** Flips per window that no sane park policy should reach. */ +export const TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT = 12 + +export type ParkVerdictFlipRecord = { + parked: boolean + windowStartMs: number + flips: number + notified: boolean +} + +/** Records park-verdict churn per tab; emits one breadcrumb per window. */ +export function recordParkVerdictFlips(args: { + records: Map + liveTabIds: ReadonlySet + nextParkedTabIds: ReadonlySet + nowMs: number + flipWindowMs?: number + noticeLimit?: number +}): void { + const { + records, + liveTabIds, + nextParkedTabIds, + nowMs, + flipWindowMs = TERMINAL_TAB_PARK_FLIP_WINDOW_MS, + noticeLimit = TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT + } = args + + for (const tabId of Array.from(records.keys())) { + if (!liveTabIds.has(tabId)) { + records.delete(tabId) + } + } + + for (const tabId of liveTabIds) { + const parked = nextParkedTabIds.has(tabId) + const record = records.get(tabId) + + if (!record) { + records.set(tabId, { parked, windowStartMs: nowMs, flips: 0, notified: false }) + continue + } + if (parked === record.parked) { + continue + } + + // Why: Date.now() jumps backwards on NTP/sleep-wake; treat any out-of-range + // elapsed value as a fresh window rather than trusting the delta. + const elapsedMs = nowMs - record.windowStartMs + if (elapsedMs >= flipWindowMs || elapsedMs < 0) { + record.windowStartMs = nowMs + record.flips = 0 + record.notified = false + } + + record.parked = parked + record.flips += 1 + + if (!record.notified && record.flips >= noticeLimit) { + record.notified = true + // Why: flips is always exactly noticeLimit here, so elapsedMs is the only + // field that separates a render loop from slow benign churn. + recordRendererCrashBreadcrumb('terminal_park_verdict_churn', { + tabId, + flips: record.flips, + elapsedMs: nowMs - record.windowStartMs, + windowMs: flipWindowMs + }) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 4c71bf66a54..c7e16910e9a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -67,7 +67,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse' import { applyTerminalAppearance, installMode2031Handlers } from './terminal-appearance' import { pushMode2031SeedReply } from './terminal-mode-2031-replies' -import { handleOsc52ClipboardRequest } from './osc52-clipboard' +import { createOsc52OscHandler } from './osc52-clipboard' import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' import { guardParserHandler } from './terminal-parser-handler-guard' @@ -110,7 +110,10 @@ import { getConnectionId } from '@/lib/connection-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' -import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent' +import { + markTerminalPinnedViewport, + releaseTerminalScrollIntentKeys +} from '@/lib/pane-manager/terminal-scroll-intent' import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' @@ -802,15 +805,17 @@ export function useTerminalPaneLifecycle({ }) mode2031DisposablesRef.current.set(pane.id, mode2031Disposables) - // OSC 52 — TUI-initiated clipboard writes (tmux/nvim/fzf/ssh). + // OSC 52 — TUI-initiated clipboard writes (Zellij/tmux/nvim/fzf/ssh). // Why: read settingsRef at fire time so mid-session gate toggles apply; return true in both paths so xterm doesn't fall through. const osc52Disposable = pane.terminal.parser.registerOscHandler( 52, - guardParserHandler('osc-52-clipboard', (data) => - handleOsc52ClipboardRequest(data, { - allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true, - writeClipboardText: window.api.ui.writeClipboardText, - onBlockedWrite: showOsc52ClipboardBlockedToast + guardParserHandler( + 'osc-52-clipboard', + createOsc52OscHandler({ + getSettingEnabled: () => settingsRef.current?.terminalAllowOsc52Clipboard, + getReplaying: () => isPaneReplaying(replayingPanesRef, pane.id), + writeClipboardText: (text) => window.api.ui.writeClipboardText(text), + showBlockedWriteToast: showOsc52ClipboardBlockedToast }) ) ) @@ -1222,6 +1227,7 @@ export function useTerminalPaneLifecycle({ } const leafId = closedPane?.leafId if (leafId && !isDetachedToTab) { + releaseTerminalScrollIntentKeys([leafId]) // Why: revoke only this pane's authority; an exact tombstone blocks queued hooks without suppressing siblings. const paneKey = makePaneKey(tabId, leafId) useAppStore.getState().retireAgentPaneAuthority(paneKey) diff --git a/src/renderer/src/hooks/metadata-request-cache.test.ts b/src/renderer/src/hooks/metadata-request-cache.test.ts index 7770aca9637..efd8cc4e687 100644 --- a/src/renderer/src/hooks/metadata-request-cache.test.ts +++ b/src/renderer/src/hooks/metadata-request-cache.test.ts @@ -3,7 +3,11 @@ import { clearMetadataRequestStore, createMetadataRequestStore, getFreshMetadata, - loadMetadata + loadMetadata, + MAX_METADATA_ERROR_SUMMARY_BYTES, + MAX_METADATA_INFLIGHT_ENTRIES, + MAX_METADATA_KEY_BYTES, + MAX_METADATA_VALUE_BYTES } from './metadata-request-cache' describe('metadata-request-cache', () => { @@ -88,7 +92,7 @@ describe('metadata-request-cache', () => { expect(store.failures.has('repo:labels')).toBe(false) }) - it('keeps failure entries isolated per key and bounded', async () => { + it('keeps failure entries isolated per key', async () => { const store = createMetadataRequestStore() await expect( loadMetadata( @@ -106,6 +110,25 @@ describe('metadata-request-cache', () => { expect(fetcherB).toHaveBeenCalledTimes(1) }) + it('bounds retained failure entries', async () => { + const store = createMetadataRequestStore() + + for (let index = 0; index <= 200; index += 1) { + await expect( + loadMetadata( + store, + `repo-${index}:labels`, + () => Promise.reject(new Error(`down-${index}`)), + () => index + ) + ).rejects.toThrow(`down-${index}`) + } + + expect(store.failures.size).toBe(200) + expect(store.failures.has('repo-0:labels')).toBe(false) + expect(store.failures.has('repo-200:labels')).toBe(true) + }) + it('does not record failures from a cleared generation', async () => { const store = createMetadataRequestStore() let rejectRequest: (error: Error) => void = () => {} @@ -123,6 +146,7 @@ describe('metadata-request-cache', () => { rejectRequest(new Error('stale failure')) await expect(pending).rejects.toThrow('stale failure') expect(store.failures.size).toBe(0) + expect(store.retainedBytes).toBe(0) const fetcher = vi.fn(() => Promise.resolve(['fresh'])) await expect(loadMetadata(store, 'repo:labels', fetcher, () => 1_500)).resolves.toEqual([ @@ -150,6 +174,41 @@ describe('metadata-request-cache', () => { await expect(pending).resolves.toEqual(['old-user']) expect(getFreshMetadata(store, 'team:members', 1_100)).toBeNull() + expect(store.retainedBytes).toBe(0) + }) + + it('does not let a cleared request release a newer request for the same key', async () => { + const store = createMetadataRequestStore() + let resolveStale: (value: string) => void = () => {} + let resolveCurrent: (value: string) => void = () => {} + const stale = loadMetadata( + store, + 'same', + () => + new Promise((resolve) => { + resolveStale = resolve + }) + ) + + clearMetadataRequestStore(store) + const current = loadMetadata( + store, + 'same', + () => + new Promise((resolve) => { + resolveCurrent = resolve + }) + ) + + resolveStale('stale') + await expect(stale).resolves.toBe('stale') + expect(store.inflight.has('same')).toBe(true) + expect(store.retainedBytes).toBe(4) + + resolveCurrent('current') + await expect(current).resolves.toBe('current') + expect(store.cache.get('same')?.data).toBe('current') + expect(store.retainedBytes).toBe(11) }) it('prunes stale cache entries when they age past the metadata ttl', async () => { @@ -183,4 +242,140 @@ describe('metadata-request-cache', () => { expect(store.cache.has('repo-0:labels')).toBe(false) expect(store.cache.get('repo-500:labels')?.data).toEqual(['label-500']) }) + + it('rejects distinct hung fetches beyond the in-flight bound', async () => { + const store = createMetadataRequestStore() + const pending = Array.from({ length: MAX_METADATA_INFLIGHT_ENTRIES }, (_, index) => + loadMetadata(store, `repo-${index}:labels`, () => new Promise(() => {})) + ) + + await expect( + loadMetadata(store, 'overflow:labels', () => Promise.resolve(['unexpected'])) + ).rejects.toThrow('queue is full') + expect(store.inflight.size).toBe(MAX_METADATA_INFLIGHT_ENTRIES) + + clearMetadataRequestStore(store) + void pending + }) + + it('accepts an exact-limit key and rejects an oversized key before fetching', async () => { + const store = createMetadataRequestStore() + const exactKey = '🙂'.repeat(MAX_METADATA_KEY_BYTES / 4) + await expect(loadMetadata(store, exactKey, () => Promise.resolve(''))).resolves.toBe('') + expect(store.cache.has(exactKey)).toBe(true) + + const fetcher = vi.fn(() => Promise.resolve('unexpected')) + await expect(loadMetadata(store, `${exactKey}x`, fetcher)).rejects.toThrow( + `exceeds ${MAX_METADATA_KEY_BYTES} bytes` + ) + expect(fetcher).not.toHaveBeenCalled() + }) + + it('retains an exact-limit value and lets an oversized value recover without retention', async () => { + const exactStore = createMetadataRequestStore() + const exactValue = 'v'.repeat(MAX_METADATA_VALUE_BYTES) + await expect( + loadMetadata(exactStore, 'exact', () => Promise.resolve(exactValue)) + ).resolves.toBe(exactValue) + expect(exactStore.cache.get('exact')?.data).toBe(exactValue) + + const oversizedStore = createMetadataRequestStore() + const oversizedValue = `${exactValue}x` + await expect( + loadMetadata(oversizedStore, 'recoverable', () => Promise.resolve(oversizedValue)) + ).resolves.toBe(oversizedValue) + expect(oversizedStore.cache.has('recoverable')).toBe(false) + + await expect( + loadMetadata(oversizedStore, 'recoverable', () => Promise.resolve('small')) + ).resolves.toBe('small') + expect(oversizedStore.cache.get('recoverable')?.data).toBe('small') + }) + + it('stores only an exact bounded error summary and reuses that summary', async () => { + const store = createMetadataRequestStore() + const error = new Error('oversized remote failure') + error.name = 'RemoteError' + error.message = 'x'.repeat(MAX_METADATA_ERROR_SUMMARY_BYTES - error.name.length + 1_000) + + await expect( + loadMetadata( + store, + 'failure', + () => Promise.reject(error), + () => 1_000 + ) + ).rejects.toBe(error) + + const cached = store.failures.get('failure') + expect((cached?.error.name.length ?? 0) + (cached?.error.message.length ?? 0)).toBe( + MAX_METADATA_ERROR_SUMMARY_BYTES + ) + expect(cached?.error.stack).toBeUndefined() + await expect( + loadMetadata( + store, + 'failure', + () => Promise.resolve('unexpected'), + () => 1_001 + ) + ).rejects.toThrow(cached?.error.message) + }) + + it('fills the aggregate budget exactly and evicts oldest retained data for recovery', async () => { + const store = createMetadataRequestStore({ maxRetainedBytes: 20 }) + + await loadMetadata( + store, + 'a', + () => Promise.resolve('x'.repeat(9)), + () => 1 + ) + await loadMetadata( + store, + 'b', + () => Promise.resolve('y'.repeat(9)), + () => 2 + ) + expect(store.retainedBytes).toBe(20) + + await loadMetadata( + store, + 'c', + () => Promise.resolve('z'.repeat(9)), + () => 3 + ) + expect(store.retainedBytes).toBe(20) + expect(store.cache.has('a')).toBe(false) + expect(store.cache.has('b')).toBe(true) + expect(store.cache.has('c')).toBe(true) + }) + + it('rejects in-flight aggregate overload and accepts work after memory is released', async () => { + const store = createMetadataRequestStore({ maxRetainedBytes: 4 }) + let resolveFirst: (value: string) => void = () => {} + const first = loadMetadata( + store, + 'aa', + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + const second = loadMetadata(store, 'bb', () => new Promise(() => {})) + + expect(store.retainedBytes).toBe(4) + await expect(loadMetadata(store, 'c', () => Promise.resolve(''))).rejects.toThrow( + 'memory budget is full' + ) + + resolveFirst('') + await expect(first).resolves.toBe('') + await expect(loadMetadata(store, 'c', () => Promise.resolve(''))).resolves.toBe('') + expect(store.retainedBytes).toBeLessThanOrEqual(4) + + clearMetadataRequestStore(store) + expect(store.retainedBytes).toBe(0) + void second + }) }) diff --git a/src/renderer/src/hooks/metadata-request-cache.ts b/src/renderer/src/hooks/metadata-request-cache.ts index 033f5e984c3..0bfae9b0f9a 100644 --- a/src/renderer/src/hooks/metadata-request-cache.ts +++ b/src/renderer/src/hooks/metadata-request-cache.ts @@ -1,3 +1,6 @@ +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' +import { boundedMetadataFailure, measureMetadataValueBytes } from './metadata-retention-measurement' + const METADATA_TTL = 300_000 // 5 min const MAX_METADATA_CACHE_ENTRIES = 500 // Why: an unreachable provider/runtime fails every consumer render; without a @@ -6,23 +9,40 @@ const MAX_METADATA_CACHE_ENTRIES = 500 // short enough that a recovered provider is picked up within seconds. const METADATA_FAILURE_TTL = 10_000 const MAX_METADATA_FAILURE_ENTRIES = 200 +export const MAX_METADATA_INFLIGHT_ENTRIES = 100 +export const MAX_METADATA_KEY_BYTES = 4 * 1024 +export const MAX_METADATA_VALUE_BYTES = 512 * 1024 +export const MAX_METADATA_ERROR_SUMMARY_BYTES = 4 * 1024 +export const MAX_METADATA_RETAINED_BYTES = 16 * 1024 * 1024 -type CachedMetadata = { data: T; fetchedAt: number } -type CachedMetadataFailure = { error: unknown; failedAt: number } +type CachedMetadata = { data: T; fetchedAt: number; retainedBytes: number } +type CachedMetadataFailure = { error: Error; failedAt: number; retainedBytes: number } + +type MetadataRequestStoreOptions = { + maxRetainedBytes?: number +} export type MetadataRequestStore = { cache: Map> inflight: Map> + inflightEntryBytes: Map failures: Map generation: number + retainedBytes: number + maxRetainedBytes: number } -export function createMetadataRequestStore(): MetadataRequestStore { +export function createMetadataRequestStore( + options: MetadataRequestStoreOptions = {} +): MetadataRequestStore { return { cache: new Map(), inflight: new Map(), + inflightEntryBytes: new Map(), failures: new Map(), - generation: 0 + generation: 0, + retainedBytes: 0, + maxRetainedBytes: clampRetainedByteLimit(options.maxRetainedBytes) } } @@ -30,7 +50,49 @@ export function clearMetadataRequestStore(store: MetadataRequestStore): vo store.generation += 1 store.cache.clear() store.inflight.clear() + store.inflightEntryBytes.clear() store.failures.clear() + store.retainedBytes = 0 +} + +function clampRetainedByteLimit(value: number | undefined): number { + if (value === undefined || !Number.isSafeInteger(value) || value <= 0) { + return MAX_METADATA_RETAINED_BYTES + } + return Math.min(value, MAX_METADATA_RETAINED_BYTES) +} + +function metadataKeyBytes(key: string): number | null { + const measured = measureUtf8ByteLength(key, { stopAfterBytes: MAX_METADATA_KEY_BYTES }) + return measured.exceededLimit ? null : measured.byteLength +} + +function deleteCachedMetadata(store: MetadataRequestStore, key: string): void { + const entry = store.cache.get(key) + if (entry && store.cache.delete(key)) { + store.retainedBytes -= entry.retainedBytes + } +} + +function deleteMetadataFailure(store: MetadataRequestStore, key: string): void { + const entry = store.failures.get(key) + if (entry && store.failures.delete(key)) { + store.retainedBytes -= entry.retainedBytes + } +} + +function releaseMetadataInflight( + store: MetadataRequestStore, + key: string, + promise: Promise +): void { + if (store.inflight.get(key) !== promise) { + return + } + store.inflight.delete(key) + const retainedBytes = store.inflightEntryBytes.get(key) ?? 0 + store.inflightEntryBytes.delete(key) + store.retainedBytes -= retainedBytes } function pruneMetadataCache( @@ -40,7 +102,7 @@ function pruneMetadataCache( ): void { for (const [key, entry] of store.cache) { if (now - entry.fetchedAt >= METADATA_TTL) { - store.cache.delete(key) + deleteCachedMetadata(store, key) } } if (store.cache.size <= maxEntries) { @@ -48,7 +110,7 @@ function pruneMetadataCache( } const sorted = [...store.cache.entries()].sort((a, b) => b[1].fetchedAt - a[1].fetchedAt) for (const [key] of sorted.slice(maxEntries)) { - store.cache.delete(key) + deleteCachedMetadata(store, key) } } @@ -57,6 +119,10 @@ export function getFreshMetadata( key: string, now = Date.now() ): CachedMetadata | null { + if (metadataKeyBytes(key) === null) { + deleteCachedMetadata(store, key) + return null + } pruneMetadataCache(store, now) const entry = store.cache.get(key) if (!entry || now - entry.fetchedAt >= METADATA_TTL) { @@ -72,7 +138,7 @@ function pruneMetadataFailures( ): void { for (const [key, entry] of store.failures) { if (now - entry.failedAt >= METADATA_FAILURE_TTL) { - store.failures.delete(key) + deleteMetadataFailure(store, key) } } if (store.failures.size <= maxEntries) { @@ -80,7 +146,7 @@ function pruneMetadataFailures( } const sorted = [...store.failures.entries()].sort((a, b) => b[1].failedAt - a[1].failedAt) for (const [key] of sorted.slice(maxEntries)) { - store.failures.delete(key) + deleteMetadataFailure(store, key) } } @@ -89,6 +155,11 @@ export function getRecentMetadataFailure( key: string, now = Date.now() ): CachedMetadataFailure | null { + if (metadataKeyBytes(key) === null) { + deleteMetadataFailure(store, key) + return null + } + pruneMetadataFailures(store, now) const entry = store.failures.get(key) if (!entry || now - entry.failedAt >= METADATA_FAILURE_TTL) { return null @@ -96,12 +167,56 @@ export function getRecentMetadataFailure( return entry } +function oldestRetainedEntry( + store: MetadataRequestStore +): { key: string; kind: 'cache' | 'failure'; retainedAt: number } | null { + let oldest: { key: string; kind: 'cache' | 'failure'; retainedAt: number } | null = null + for (const [key, entry] of store.failures) { + if (!oldest || entry.failedAt < oldest.retainedAt) { + oldest = { key, kind: 'failure', retainedAt: entry.failedAt } + } + } + for (const [key, entry] of store.cache) { + if (!oldest || entry.fetchedAt < oldest.retainedAt) { + oldest = { key, kind: 'cache', retainedAt: entry.fetchedAt } + } + } + return oldest +} + +function reserveMetadataRetention( + store: MetadataRequestStore, + retainedBytes: number, + now: number +): boolean { + pruneMetadataCache(store, now) + pruneMetadataFailures(store, now) + while (store.retainedBytes + retainedBytes > store.maxRetainedBytes) { + const oldest = oldestRetainedEntry(store) + if (!oldest) { + return false + } + if (oldest.kind === 'cache') { + deleteCachedMetadata(store, oldest.key) + } else { + deleteMetadataFailure(store, oldest.key) + } + } + return true +} + export function loadMetadata( store: MetadataRequestStore, key: string, fetcher: () => Promise, now = Date.now ): Promise { + const keyBytes = metadataKeyBytes(key) + if (keyBytes === null) { + return Promise.reject( + new Error(`Metadata request key exceeds ${MAX_METADATA_KEY_BYTES} bytes.`) + ) + } const cached = getFreshMetadata(store, key, now()) if (cached) { return Promise.resolve(cached.data) @@ -117,36 +232,66 @@ export function loadMetadata( return Promise.reject(recentFailure.error) } + if (store.inflight.size >= MAX_METADATA_INFLIGHT_ENTRIES) { + return Promise.reject(new Error('Metadata request queue is full; retry after requests finish.')) + } + if (!reserveMetadataRetention(store, keyBytes, now())) { + return Promise.reject(new Error('Metadata request memory budget is full; retry later.')) + } + // Why: clearMetadataRequestStore invalidates auth/repo boundaries; late // responses from the previous generation must not repopulate the cache. const generation = store.generation - const promise = fetcher() - .then((data) => { - if (store.generation === generation) { - const fetchedAt = now() - store.cache.set(key, { data, fetchedAt }) - store.failures.delete(key) - // Why: these module-level stores are reused across dialogs and - // repo/runtime keys; TTL controls freshness but also needs pruning so - // long sessions do not retain stale metadata indefinitely. - pruneMetadataCache(store, fetchedAt) + let fetched: Promise + try { + fetched = fetcher() + } catch (error) { + return Promise.reject(error) + } + let promise!: Promise + promise = fetched + .then( + (data) => { + releaseMetadataInflight(store, key, promise) + if (store.generation === generation) { + const fetchedAt = now() + const valueBytes = measureMetadataValueBytes(data, MAX_METADATA_VALUE_BYTES) + deleteCachedMetadata(store, key) + deleteMetadataFailure(store, key) + if ( + valueBytes !== null && + reserveMetadataRetention(store, keyBytes + valueBytes, fetchedAt) + ) { + const retainedBytes = keyBytes + valueBytes + store.cache.set(key, { data, fetchedAt, retainedBytes }) + store.retainedBytes += retainedBytes + pruneMetadataCache(store, fetchedAt) + } + } + return data + }, + (error: unknown) => { + releaseMetadataInflight(store, key, promise) + if (store.generation === generation) { + const failedAt = now() + const bounded = boundedMetadataFailure(error, MAX_METADATA_ERROR_SUMMARY_BYTES) + const retainedBytes = keyBytes + bounded.bytes + deleteMetadataFailure(store, key) + if (reserveMetadataRetention(store, retainedBytes, failedAt)) { + store.failures.set(key, { error: bounded.error, failedAt, retainedBytes }) + store.retainedBytes += retainedBytes + pruneMetadataFailures(store, failedAt) + } + } + throw error } - return data - }) - .catch((error: unknown) => { - if (store.generation === generation) { - const failedAt = now() - store.failures.set(key, { error, failedAt }) - pruneMetadataFailures(store, failedAt) - } - throw error - }) + ) .finally(() => { - if (store.inflight.get(key) === promise) { - store.inflight.delete(key) - } + releaseMetadataInflight(store, key, promise) }) store.inflight.set(key, promise) + store.inflightEntryBytes.set(key, keyBytes) + store.retainedBytes += keyBytes return promise } diff --git a/src/renderer/src/hooks/metadata-retention-measurement.ts b/src/renderer/src/hooks/metadata-retention-measurement.ts new file mode 100644 index 00000000000..d54e1bf67b3 --- /dev/null +++ b/src/renderer/src/hooks/metadata-retention-measurement.ts @@ -0,0 +1,135 @@ +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' + +const MAX_METADATA_VALUE_DEPTH = 64 +const MAX_METADATA_ERROR_NAME_BYTES = 256 + +export function measureMetadataValueBytes(value: unknown, maxBytes: number): number | null { + let retainedBytes = 0 + const visited = new WeakSet() + const addBytes = (bytes: number): boolean => { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > maxBytes - retainedBytes) { + return false + } + retainedBytes += bytes + return true + } + const visit = (candidate: unknown, depth: number): boolean => { + if (candidate === null || candidate === undefined) { + return addBytes(4) + } + if (typeof candidate === 'string') { + const measured = measureUtf8ByteLength(candidate, { + stopAfterBytes: maxBytes - retainedBytes + }) + return !measured.exceededLimit && addBytes(measured.byteLength) + } + if (typeof candidate === 'number' || typeof candidate === 'bigint') { + return addBytes(8) + } + if (typeof candidate === 'boolean') { + return addBytes(4) + } + if (typeof candidate !== 'object' || depth > MAX_METADATA_VALUE_DEPTH) { + return false + } + if (visited.has(candidate)) { + return true + } + visited.add(candidate) + if (candidate instanceof ArrayBuffer) { + return addBytes(candidate.byteLength) + } + if (ArrayBuffer.isView(candidate)) { + return addBytes(candidate.byteLength) + } + if (typeof Blob !== 'undefined' && candidate instanceof Blob) { + return addBytes(candidate.size) + } + if (!addBytes(32)) { + return false + } + if (Array.isArray(candidate) && !addBytes(candidate.length * 8)) { + return false + } + if (candidate instanceof Map) { + if (!addBytes(candidate.size * 16)) { + return false + } + for (const [key, item] of candidate) { + if (!visit(key, depth + 1) || !visit(item, depth + 1)) { + return false + } + } + return true + } + if (candidate instanceof Set) { + if (!addBytes(candidate.size * 8)) { + return false + } + for (const item of candidate) { + if (!visit(item, depth + 1)) { + return false + } + } + return true + } + for (const key in candidate) { + if (!Object.prototype.hasOwnProperty.call(candidate, key)) { + continue + } + const keyMeasurement = measureUtf8ByteLength(key, { + stopAfterBytes: maxBytes - retainedBytes + }) + if ( + keyMeasurement.exceededLimit || + !addBytes(keyMeasurement.byteLength) || + !addBytes(16) || + !visit((candidate as Record)[key], depth + 1) + ) { + return false + } + } + return true + } + + try { + return visit(value, 0) ? retainedBytes : null + } catch { + return null + } +} + +function takeUtf8Prefix(value: string, maxBytes: number): { text: string; bytes: number } { + let bytes = 0 + let end = 0 + while (end < value.length) { + const codePoint = value.codePointAt(end) ?? 0 + const codePointText = String.fromCodePoint(codePoint) + const codePointBytes = measureUtf8ByteLength(codePointText).byteLength + if (bytes + codePointBytes > maxBytes) { + break + } + bytes += codePointBytes + end += codePoint > 0xffff ? 2 : 1 + } + return { text: value.slice(0, end), bytes } +} + +export function boundedMetadataFailure( + error: unknown, + maxSummaryBytes: number +): { error: Error; bytes: number } { + const rawName = error instanceof Error && error.name ? error.name : 'Error' + const rawMessage = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'Metadata request failed' + const name = takeUtf8Prefix(rawName, Math.min(MAX_METADATA_ERROR_NAME_BYTES, maxSummaryBytes)) + const message = takeUtf8Prefix(rawMessage, Math.max(0, maxSummaryBytes - name.bytes)) + const cachedError = new Error(message.text) + cachedError.name = name.text || 'Error' + cachedError.stack = undefined + return { error: cachedError, bytes: name.bytes + message.bytes } +} diff --git a/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts b/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts index 55cb0f28674..10842c84b74 100644 --- a/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts +++ b/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts @@ -102,4 +102,58 @@ describe('createRuntimeProjectRefreshScheduler', () => { expect(refresh).not.toHaveBeenCalled() }) + + it('bounds environment records while refreshes are stalled', async () => { + const refresh = vi.fn(() => new Promise(() => {})) + const scheduler = createRuntimeProjectRefreshScheduler({ + refresh, + debounceMs: 100, + minIntervalMs: 1_000, + maxEntries: 1 + }) + + scheduler.request('env-1') + scheduler.request('env-2') + await vi.advanceTimersByTimeAsync(100) + + expect(refresh).toHaveBeenCalledTimes(1) + expect(refresh).toHaveBeenCalledWith('env-1') + scheduler.stop() + }) + + it('recovers capacity by evicting an idle environment record', async () => { + const refresh = vi.fn().mockResolvedValue(undefined) + const scheduler = createRuntimeProjectRefreshScheduler({ + refresh, + debounceMs: 100, + minIntervalMs: 1_000, + maxEntries: 1 + }) + + scheduler.request('env-1') + await vi.advanceTimersByTimeAsync(100) + scheduler.request('env-2') + await vi.advanceTimersByTimeAsync(100) + + expect(refresh.mock.calls).toEqual([['env-1'], ['env-2']]) + scheduler.stop() + }) + + it('admits exact-limit environment ids and ignores oversized ids', async () => { + const refresh = vi.fn().mockResolvedValue(undefined) + const scheduler = createRuntimeProjectRefreshScheduler({ + refresh, + debounceMs: 100, + minIntervalMs: 1_000, + maxEnvironmentIdBytes: 4 + }) + + scheduler.request('🐋') + scheduler.request('🐋x') + await vi.advanceTimersByTimeAsync(100) + + expect(refresh).toHaveBeenCalledTimes(1) + expect(refresh).toHaveBeenCalledWith('🐋') + scheduler.stop() + }) }) diff --git a/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts b/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts index 46bf354667f..3e39777e3f4 100644 --- a/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts +++ b/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts @@ -1,9 +1,13 @@ +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' + export type RuntimeProjectRefreshSchedulerDeps = { refresh: (environmentId: string) => Promise debounceMs?: number minIntervalMs?: number now?: () => number onError?: (error: unknown) => void + maxEntries?: number + maxEnvironmentIdBytes?: number } export type RuntimeProjectRefreshScheduler = { @@ -16,10 +20,13 @@ type RefreshEntry = { lastStartedAt: number pending: boolean timer: ReturnType | null + expiryTimer: ReturnType | null } const DEFAULT_DEBOUNCE_MS = 250 const DEFAULT_MIN_INTERVAL_MS = 5_000 +export const RUNTIME_PROJECT_REFRESH_MAX_ENTRIES = 256 +export const RUNTIME_PROJECT_REFRESH_MAX_ENVIRONMENT_ID_BYTES = 16 * 1024 export function createRuntimeProjectRefreshScheduler( deps: RuntimeProjectRefreshSchedulerDeps @@ -27,27 +34,84 @@ export function createRuntimeProjectRefreshScheduler( const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS const minIntervalMs = deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS const now = deps.now ?? Date.now + const maxEntries = deps.maxEntries ?? RUNTIME_PROJECT_REFRESH_MAX_ENTRIES + const maxEnvironmentIdBytes = + deps.maxEnvironmentIdBytes ?? RUNTIME_PROJECT_REFRESH_MAX_ENVIRONMENT_ID_BYTES const entries = new Map() let stopped = false - const getEntry = (environmentId: string): RefreshEntry => { - let entry = entries.get(environmentId) - if (!entry) { - entry = { - inFlight: false, - lastStartedAt: 0, - pending: false, - timer: null - } - entries.set(environmentId, entry) + const clearExpiryTimer = (entry: RefreshEntry): void => { + if (entry.expiryTimer) { + clearTimeout(entry.expiryTimer) + entry.expiryTimer = null } + } + + const evictIdleEntry = (): boolean => { + for (const [environmentId, entry] of entries) { + if (entry.inFlight || entry.pending || entry.timer) { + continue + } + clearExpiryTimer(entry) + entries.delete(environmentId) + return true + } + return false + } + + const getEntry = (environmentId: string): RefreshEntry | null => { + let entry = entries.get(environmentId) + if (entry) { + entries.delete(environmentId) + entries.set(environmentId, entry) + clearExpiryTimer(entry) + return entry + } + if (entries.size >= maxEntries && !evictIdleEntry()) { + return null + } + entry = { + inFlight: false, + lastStartedAt: 0, + pending: false, + timer: null, + expiryTimer: null + } + entries.set(environmentId, entry) return entry } + const expireWhenThrottleWindowEnds = (environmentId: string, entry: RefreshEntry): void => { + if (stopped || entry.inFlight || entry.pending || entry.timer) { + return + } + clearExpiryTimer(entry) + const elapsed = entry.lastStartedAt > 0 ? now() - entry.lastStartedAt : minIntervalMs + const delay = Math.max(0, minIntervalMs - elapsed) + if (delay === 0) { + if (entries.get(environmentId) === entry) { + entries.delete(environmentId) + } + return + } + entry.expiryTimer = setTimeout(() => { + entry.expiryTimer = null + if ( + entries.get(environmentId) === entry && + !entry.inFlight && + !entry.pending && + !entry.timer + ) { + entries.delete(environmentId) + } + }, delay) + } + const schedule = (environmentId: string, entry: RefreshEntry): void => { if (stopped || entry.inFlight || entry.timer) { return } + clearExpiryTimer(entry) const elapsed = entry.lastStartedAt > 0 ? now() - entry.lastStartedAt : minIntervalMs const throttleDelay = Math.max(0, minIntervalMs - elapsed) const delay = Math.max(debounceMs, throttleDelay) @@ -74,16 +138,27 @@ export function createRuntimeProjectRefreshScheduler( // Why: runtime repo events can be noisy while a remote server is merely // connected; keep discovery live without letting it drive the renderer. schedule(environmentId, entry) + } else { + expireWhenThrottleWindowEnds(environmentId, entry) } } } const request = (environmentId: string): void => { + if ( + stopped || + measureUtf8ByteLength(environmentId, { stopAfterBytes: maxEnvironmentIdBytes }).exceededLimit + ) { + return + } const trimmedEnvironmentId = environmentId.trim() - if (!trimmedEnvironmentId || stopped) { + if (!trimmedEnvironmentId) { return } const entry = getEntry(trimmedEnvironmentId) + if (!entry) { + return + } entry.pending = true schedule(trimmedEnvironmentId, entry) } @@ -94,6 +169,7 @@ export function createRuntimeProjectRefreshScheduler( if (entry.timer) { clearTimeout(entry.timer) } + clearExpiryTimer(entry) } entries.clear() } diff --git a/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.test.ts b/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.test.ts new file mode 100644 index 00000000000..8e5d6d64d98 --- /dev/null +++ b/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SshTargetSummary } from '../../../shared/ssh-types' +import { + UnknownSshTargetStateCoordinator, + UNKNOWN_SSH_TARGET_STATE_MAX_PENDING +} from './unknown-ssh-target-state-coordinator' + +function deferredTargets(): { + promise: Promise + resolve: (targets: SshTargetSummary[]) => void + reject: (error: unknown) => void +} { + let resolve!: (targets: SshTargetSummary[]) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function state(targetId: string, status: 'connecting' | 'error', error: string | null = null) { + return { targetId, status, error, reconnectAttempt: status === 'error' ? 1 : 0 } +} + +function setup(listTargets: () => Promise) { + const dependencies = { + listTargets: vi.fn(listTargets), + setTargetsMetadata: vi.fn(), + applyState: vi.fn(), + clearRemovedTargetState: vi.fn() + } + return { + dependencies, + coordinator: new UnknownSshTargetStateCoordinator(dependencies) + } +} + +describe('unknown SSH target state coordinator', () => { + it('keeps one target refresh in flight and applies only the latest state per target', async () => { + const refresh = deferredTargets() + const { coordinator, dependencies } = setup(() => refresh.promise) + + coordinator.enqueue('ssh-a', state('ssh-a', 'connecting')) + coordinator.enqueue('ssh-a', state('ssh-a', 'error', 'failed')) + coordinator.enqueue('ssh-removed', state('ssh-removed', 'error', 'gone')) + expect(dependencies.listTargets).toHaveBeenCalledOnce() + + refresh.resolve([{ id: 'ssh-a', label: 'Remote A' }]) + await refresh.promise + await vi.waitFor(() => { + expect(dependencies.clearRemovedTargetState).toHaveBeenCalledWith('ssh-removed') + }) + + expect(dependencies.setTargetsMetadata).toHaveBeenCalledWith([ + { id: 'ssh-a', label: 'Remote A' } + ]) + expect(dependencies.applyState).toHaveBeenCalledOnce() + expect(dependencies.applyState).toHaveBeenCalledWith( + 'ssh-a', + expect.objectContaining({ status: 'error', error: 'failed' }) + ) + }) + + it('retries once and falls back to the latest state after both refreshes fail', async () => { + const { coordinator, dependencies } = setup(() => Promise.reject(new Error('offline'))) + + coordinator.enqueue('ssh-a', state('ssh-a', 'connecting')) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(dependencies.listTargets).toHaveBeenCalledTimes(2) + expect(dependencies.applyState).toHaveBeenCalledWith( + 'ssh-a', + expect.objectContaining({ status: 'connecting' }) + ) + }) + + it('rechecks a target first seen after an in-flight snapshot began', async () => { + const first = deferredTargets() + const second = deferredTargets() + const { coordinator, dependencies } = setup( + vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + ) + coordinator.enqueue('ssh-a', state('ssh-a', 'connecting')) + coordinator.enqueue('ssh-b', state('ssh-b', 'connecting')) + + first.resolve([{ id: 'ssh-a', label: 'Remote A' }]) + await first.promise + await Promise.resolve() + + expect(dependencies.applyState).toHaveBeenCalledWith( + 'ssh-a', + expect.objectContaining({ status: 'connecting' }) + ) + expect(dependencies.clearRemovedTargetState).not.toHaveBeenCalledWith('ssh-b') + expect(dependencies.listTargets).toHaveBeenCalledTimes(2) + + second.resolve([ + { id: 'ssh-a', label: 'Remote A' }, + { id: 'ssh-b', label: 'Remote B' } + ]) + await second.promise + await Promise.resolve() + + expect(dependencies.applyState).toHaveBeenCalledWith( + 'ssh-b', + expect.objectContaining({ status: 'connecting' }) + ) + }) + + it('caps pending state while a shared target refresh hangs', async () => { + const refresh = deferredTargets() + const { coordinator, dependencies } = setup(() => refresh.promise) + for (let index = 0; index <= UNKNOWN_SSH_TARGET_STATE_MAX_PENDING; index += 1) { + const targetId = `ssh-${index}` + coordinator.enqueue(targetId, state(targetId, 'connecting')) + } + + expect(coordinator.evidence()).toEqual({ + pending: UNKNOWN_SSH_TARGET_STATE_MAX_PENDING, + refreshInFlight: true + }) + expect(dependencies.listTargets).toHaveBeenCalledOnce() + + refresh.resolve( + Array.from({ length: UNKNOWN_SSH_TARGET_STATE_MAX_PENDING + 1 }, (_, index) => ({ + id: `ssh-${index}`, + label: `Remote ${index}` + })) + ) + await refresh.promise + await vi.waitFor(() => { + expect(dependencies.applyState).toHaveBeenCalledTimes(UNKNOWN_SSH_TARGET_STATE_MAX_PENDING) + }) + expect(dependencies.applyState).not.toHaveBeenCalledWith('ssh-0', expect.anything()) + }) + + it('drops malformed state without starting a refresh', () => { + const { coordinator, dependencies } = setup(() => Promise.resolve([])) + + expect(coordinator.enqueue('ssh-a', { status: 'connected' })).toBe(false) + expect(dependencies.listTargets).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.ts b/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.ts new file mode 100644 index 00000000000..b723ff55ad2 --- /dev/null +++ b/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.ts @@ -0,0 +1,113 @@ +import type { SshConnectionState, SshTargetSummary } from '../../../shared/ssh-types' +import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' + +export const UNKNOWN_SSH_TARGET_STATE_MAX_PENDING = 256 + +type UnknownSshTargetStateCoordinatorDependencies = { + listTargets: () => Promise + setTargetsMetadata: (targets: SshTargetSummary[]) => void + applyState: (targetId: string, state: SshConnectionState) => void + clearRemovedTargetState: (targetId: string) => void +} + +export class UnknownSshTargetStateCoordinator { + private readonly pending = new Map() + private refreshInFlight = false + private disposed = false + private nextEventId = 0 + + constructor( + private readonly dependencies: UnknownSshTargetStateCoordinatorDependencies, + private readonly maxPending = UNKNOWN_SSH_TARGET_STATE_MAX_PENDING + ) {} + + enqueue(targetId: string, value: unknown): boolean { + const state = admitSshConnectionState(value, targetId) + if (!state || this.disposed) { + return false + } + const previous = this.pending.get(targetId) + if (previous) { + this.pending.delete(targetId) + } else if (this.pending.size >= this.maxPending) { + const oldestTargetId = this.pending.keys().next().value + if (oldestTargetId !== undefined) { + this.pending.delete(oldestTargetId) + } + } + this.pending.set(targetId, { + state, + eventId: ++this.nextEventId + }) + this.ensureRefresh() + return true + } + + forget(targetId: string): void { + this.pending.delete(targetId) + } + + dispose(): void { + this.disposed = true + this.pending.clear() + } + + evidence(): { pending: number; refreshInFlight: boolean } { + return { pending: this.pending.size, refreshInFlight: this.refreshInFlight } + } + + private ensureRefresh(): void { + if (this.refreshInFlight || this.disposed) { + return + } + this.refreshInFlight = true + const requestStartEventId = this.nextEventId + void this.dependencies + .listTargets() + // Why: one retry preserves the prior transient-failure behavior without one request per event. + .catch(() => this.dependencies.listTargets()) + .then( + (targets) => this.finish(targets, requestStartEventId), + () => this.finish(null, requestStartEventId) + ) + } + + private finish(targets: SshTargetSummary[] | null, requestStartEventId: number): void { + if (this.disposed) { + return + } + this.refreshInFlight = false + if (targets) { + const targetIds = new Set(targets.map((target) => target.id)) + const applicable = Array.from(this.pending).filter( + ([, entry]) => entry.eventId <= requestStartEventId + ) + for (const [targetId] of applicable) { + this.pending.delete(targetId) + } + if (applicable.length > 0) { + this.dependencies.setTargetsMetadata(targets) + } + for (const [targetId, entry] of applicable) { + if (targetIds.has(targetId)) { + this.dependencies.applyState(targetId, entry.state) + } else { + this.dependencies.clearRemovedTargetState(targetId) + } + } + } else { + const applicable = Array.from(this.pending).filter( + ([, entry]) => entry.eventId <= requestStartEventId + ) + for (const [targetId] of applicable) { + this.pending.delete(targetId) + } + for (const [targetId, entry] of applicable) { + this.dependencies.applyState(targetId, entry.state) + } + } + if (this.pending.size > 0) { + this.ensureRefresh() + } + } +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index e32043a8de9..fe4036b4888 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1704,14 +1704,14 @@ describe('useIpcEvents updater integration', () => { state: errorState }) - expect(pendingListTargets).toHaveLength(2) - const resolveConnectingTargets = pendingListTargets.shift()!.resolve - const resolveErrorTargets = pendingListTargets.shift()!.resolve + expect(pendingListTargets).toHaveLength(1) + const resolveFirstTargets = pendingListTargets.shift()!.resolve const targets = [{ id: 'conn-new', label: 'New remote' }] - resolveErrorTargets(targets) + resolveFirstTargets(targets) await Promise.resolve() await Promise.resolve() - resolveConnectingTargets(targets) + expect(pendingListTargets).toHaveLength(1) + pendingListTargets.shift()!.resolve(targets) await Promise.resolve() await Promise.resolve() @@ -6746,290 +6746,6 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) - it('queues replayed startup snapshots until the pane hydrates', async () => { - const setAgentStatus = vi.fn() - const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null } - let resolveSnapshot!: (entries: AgentStatusSetData[]) => void - const getSnapshot = vi.fn( - () => - new Promise((resolve) => { - resolveSnapshot = resolve - }) - ) - - const storeState: StoreLike = buildStoreState({ - setAgentStatus, - workspaceSessionReady: true, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - repos: [], - worktreesByRepo: {} - }) - - stubReactSyncEffect() - vi.doMock('../store', () => ({ - useAppStore: { - subscribe: vi.fn((listener: StoreSubscribeListener) => { - subscribeListenerRef.current = listener - return () => { - subscribeListenerRef.current = null - } - }), - getState: () => storeState - } - })) - stubAuxiliaryModules() - vi.stubGlobal( - 'window', - buildWindowApi({ - getSnapshot, - onSet: () => () => {} - }) - ) - - const { useIpcEvents } = await import('./useIpcEvents') - - useIpcEvents() - await Promise.resolve() - - resolveSnapshot([ - { - paneKey: FUTURE_PANE_KEY, - tabId: 'tab-future', - worktreeId: 'wt-1', - connectionId: 'ssh-1', - state: 'working', - prompt: 'PreToolUse: Bash', - agentType: 'codex', - toolName: 'Bash', - toolInput: 'pnpm test', - terminalHandle: 'term-future', - receivedAt: 1_700_000_000_000, - stateStartedAt: 1_699_999_999_000 - } - ]) - await Promise.resolve() - await Promise.resolve() - - expect(setAgentStatus).not.toHaveBeenCalled() - - Object.assign(storeState, { - tabsByWorktree: { - 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'SSH Tab' }] - }, - terminalLayoutsByTabId: { - 'tab-future': { - root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, - activeLeafId: FUTURE_LEAF_ID, - expandedLeafId: null - } - } - }) - subscribeListenerRef.current?.(storeState, storeState) - - expect(setAgentStatus).toHaveBeenCalledTimes(1) - expect(setAgentStatus).toHaveBeenCalledWith( - FUTURE_PANE_KEY, - expect.objectContaining({ - state: 'working', - prompt: 'PreToolUse: Bash', - agentType: 'codex', - toolName: 'Bash', - toolInput: 'pnpm test' - }), - 'SSH Tab', - { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, - expectWorktreeRouting('wt-1'), - undefined - ) - }) - - it('suppresses notifications for replayed done snapshots after pane hydration', async () => { - const setAgentStatus = vi.fn() - const observeAgentHookCompletionForNotification = vi.fn() - const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null } - let resolveSnapshot!: (entries: AgentStatusSetData[]) => void - const getSnapshot = vi.fn( - () => - new Promise((resolve) => { - resolveSnapshot = resolve - }) - ) - - const storeState: StoreLike = buildStoreState({ - setAgentStatus, - workspaceSessionReady: true, - settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - repos: [], - worktreesByRepo: {} - }) - - stubReactSyncEffect() - vi.doMock('../store', () => ({ - useAppStore: { - subscribe: vi.fn((listener: StoreSubscribeListener) => { - subscribeListenerRef.current = listener - return () => { - subscribeListenerRef.current = null - } - }), - getState: () => storeState - } - })) - vi.doMock('./agent-hook-completion-notifications', () => ({ - observeAgentHookCompletionForNotification, - resetAgentHookCompletionNotificationCoordinators: vi.fn(), - syncAgentHookCompletionNotificationSettings: vi.fn(), - syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() - })) - stubAuxiliaryModules() - vi.stubGlobal( - 'window', - buildWindowApi({ - getSnapshot, - onSet: () => () => {} - }) - ) - - const { useIpcEvents } = await import('./useIpcEvents') - - useIpcEvents() - await Promise.resolve() - - resolveSnapshot([ - { - paneKey: FUTURE_PANE_KEY, - tabId: 'tab-future', - worktreeId: 'wt-1', - connectionId: 'ssh-1', - state: 'done', - prompt: 'remote completion', - agentType: 'codex', - lastAssistantMessage: 'queued completion', - terminalHandle: 'term-future', - receivedAt: 1_700_000_000_000, - stateStartedAt: 1_699_999_999_000 - } - ]) - await Promise.resolve() - await Promise.resolve() - - expect(setAgentStatus).not.toHaveBeenCalled() - - Object.assign(storeState, { - tabsByWorktree: { - 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'SSH Tab' }] - }, - terminalLayoutsByTabId: { - 'tab-future': { - root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, - activeLeafId: FUTURE_LEAF_ID, - expandedLeafId: null - } - } - }) - subscribeListenerRef.current?.(storeState, storeState) - - expect(setAgentStatus).toHaveBeenCalledWith( - FUTURE_PANE_KEY, - expect.objectContaining({ - state: 'done', - prompt: 'remote completion', - agentType: 'codex', - lastAssistantMessage: 'queued completion' - }), - 'SSH Tab', - { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, - expectWorktreeRouting('wt-1'), - undefined - ) - expect(observeAgentHookCompletionForNotification).not.toHaveBeenCalled() - }) - - it('drops replayed startup snapshots after hydration when connection ownership mismatches', async () => { - const setAgentStatus = vi.fn() - const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null } - let resolveSnapshot!: (entries: AgentStatusSetData[]) => void - const getSnapshot = vi.fn( - () => - new Promise((resolve) => { - resolveSnapshot = resolve - }) - ) - - const storeState: StoreLike = buildStoreState({ - setAgentStatus, - workspaceSessionReady: true, - repos: [{ id: 'repo-1', connectionId: 'conn-actual' }], - worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }, - tabsByWorktree: {}, - terminalLayoutsByTabId: {} - }) - - stubReactSyncEffect() - vi.doMock('../store', () => ({ - useAppStore: { - subscribe: vi.fn((listener: StoreSubscribeListener) => { - subscribeListenerRef.current = listener - return () => { - subscribeListenerRef.current = null - } - }), - getState: () => storeState - } - })) - stubAuxiliaryModules() - vi.stubGlobal( - 'window', - buildWindowApi({ - getSnapshot, - onSet: () => () => {} - }) - ) - - const { useIpcEvents } = await import('./useIpcEvents') - - useIpcEvents() - await Promise.resolve() - - resolveSnapshot([ - { - paneKey: FUTURE_PANE_KEY, - tabId: 'tab-future', - worktreeId: 'wt-1', - connectionId: 'ssh-stale', - state: 'done', - prompt: 'remote completion', - agentType: 'codex', - terminalHandle: 'term-future', - receivedAt: 1_700_000_000_000, - stateStartedAt: 1_699_999_999_000 - } - ]) - await Promise.resolve() - await Promise.resolve() - - expect(setAgentStatus).not.toHaveBeenCalled() - - Object.assign(storeState, { - tabsByWorktree: { - 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'SSH Tab' }] - }, - terminalLayoutsByTabId: { - 'tab-future': { - root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, - activeLeafId: FUTURE_LEAF_ID, - expandedLeafId: null - } - } - }) - subscribeListenerRef.current?.(storeState, storeState) - - expect(setAgentStatus).not.toHaveBeenCalled() - }) - it('accepts WSL-relayed status events for a local repo (wsl:* is transport provenance, not ownership)', async () => { const setAgentStatus = vi.fn() const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 9b6439e5989..6bb00674ea7 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -32,6 +32,7 @@ import type { } from '../../../shared/remote-workspace-types' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { SshConnectionState } from '../../../shared/ssh-types' +import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' import type { RuntimeBrowserDriverState, @@ -89,6 +90,8 @@ import { import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' +import { recentlyRenamedWorktreeGrace } from './worktree-rename-grace-retention' +import { UnknownSshTargetStateCoordinator } from './unknown-ssh-target-state-coordinator' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification' import { @@ -101,6 +104,7 @@ import { detectLanguage } from '@/lib/language-detect' import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' import { track } from '@/lib/telemetry' +import { TransientAgentStatusClearRegistry } from '@/lib/transient-agent-status-clear-retention' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' @@ -250,7 +254,6 @@ const MAX_PENDING_AGENT_STATUS_EVENTS = 100 const MAX_PENDING_MOBILE_STATE_EVENTS = 300 // Why: a rename's event burst lags the on-disk move; shield both ids from the deletion diff for a grace window. const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000 -const recentlyRenamedWorktreeIdExpiry = new Map() let remoteWorkspaceSnapshotApplyDepth = 0 let remoteWorkspaceSnapshotWriteSuppressUntil = 0 const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000 @@ -437,7 +440,7 @@ async function prepareRemoteWorkspaceTarget(targetId: string): Promise await store.fetchRepos() repos = useAppStore.getState().repos.filter((repo) => repo.connectionId === targetId) } - await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id))) + await refreshRuntimeProjectWorktrees(repos) await useAppStore.getState().fetchWorktreeLineage() return true } @@ -844,11 +847,10 @@ export function useIpcEvents(): void { type PendingAgentStatusEvent = { data: AgentStatusIpcPayload firstSeenAt: number - replay: boolean } type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped' const pendingAgentStatusEvents: PendingAgentStatusEvent[] = [] - const transientClearWatermarkByConnectionId = new Map() + const transientClearWatermarks = new TransientAgentStatusClearRegistry() let agentStatusEffectDisposed = false let pendingAgentStatusRetryTimer: ReturnType | null = null // Why: setAgentStatus notifies synchronously and re-enters this flush mid-drain; guard re-entrancy (crash 9fc89529). @@ -869,8 +871,10 @@ export function useIpcEvents(): void { if (renamed) { // Shield both ids from the deletion diff across the rename's event burst — the worktree list lags the on-disk move. const expiry = Date.now() + WORKTREE_RENAME_PURGE_GRACE_MS - recentlyRenamedWorktreeIdExpiry.set(renamed.oldWorktreeId, expiry) - recentlyRenamedWorktreeIdExpiry.set(renamed.newWorktreeId, expiry) + recentlyRenamedWorktreeGrace.remember( + [renamed.oldWorktreeId, renamed.newWorktreeId], + expiry + ) useAppStore.getState().migrateWorktreeIdentity(renamed.oldWorktreeId, renamed.newWorktreeId) } // Why: diff before/after fetch to catch out-of-band deletions and purge worktree state, else zombie ptyId entries leak (design §2c, §4.4). @@ -889,14 +893,7 @@ export function useIpcEvents(): void { if (renamedWasActive && renamed) { useAppStore.getState().setActiveWorktree(renamed.newWorktreeId) } - // Sweep expired rename-grace entries before any early return, else forced-local - // (or non-authoritative) events let the map grow for the session. const now = Date.now() - for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) { - if (expiry <= now) { - recentlyRenamedWorktreeIdExpiry.delete(id) - } - } // Why: the deletion diff below is repo-wide, but a forced-local scan overlapping // a runtime cannot prove remote absence (legacy runtime rows may lack hostId). // fetchWorktrees still purges removed local rows host-scoped; accepted gap: the @@ -918,8 +915,7 @@ export function useIpcEvents(): void { continue } // A recently renamed worktree's old/new id isn't a deletion — its state moved to the new id; the list just lags. - const graceExpiry = recentlyRenamedWorktreeIdExpiry.get(id) - if (graceExpiry != null && graceExpiry > now) { + if (recentlyRenamedWorktreeGrace.protects(id, now)) { continue } removed.push(id) @@ -2749,7 +2745,7 @@ export function useIpcEvents(): void { } if (state.status === 'connected') { - void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(async () => { + void refreshRuntimeProjectWorktrees(remoteRepos).then(async () => { await useAppStore.getState().fetchWorktreeLineage() // Why: panes that never spawned (no PTY provider at cold start) or whose deferred reattach never ran sit inert. // Bumping generation remounts TerminalPane so the deferred-connect gate reattaches or spawns fresh now that the provider exists. @@ -2789,44 +2785,28 @@ export function useIpcEvents(): void { } } - let sshTargetStateEventId = 0 - const latestSshTargetStateEventByTargetId = new Map() + const unknownSshTargetStates = new UnknownSshTargetStateCoordinator({ + listTargets: () => window.api.ssh.listTargets(), + setTargetsMetadata: (targets) => useAppStore.getState().setSshTargetsMetadata(targets), + applyState: applySshConnectionStateChange, + clearRemovedTargetState: (targetId) => + useAppStore.getState().clearRemovedSshTargetState(targetId) + }) + unsubs.push(() => unknownSshTargetStates.dispose()) handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => { + const state = admitSshConnectionState(data.state, data.targetId) + if (!state) { + return + } const store = useAppStore.getState() - const state = data.state as SshConnectionState - const stateEventId = ++sshTargetStateEventId - latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) if (!store.sshTargetLabels.has(data.targetId)) { // Why: unknown target id could be a post-boot add or a removed target racing disconnect; confirm with main first. - window.api.ssh - .listTargets() - // Why: refresh doubles as a deletion guard; retry once so a transient IPC failure doesn't drop a real added-target event. - .catch(() => window.api.ssh.listTargets()) - .then((targets) => { - if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { - return - } - latestSshTargetStateEventByTargetId.delete(data.targetId) - const latestStore = useAppStore.getState() - if (!targets.some((target) => target.id === data.targetId)) { - // Why: state events can race after target removal; absence from main's target list means deletion, not a new target. - latestStore.clearRemovedSshTargetState(data.targetId) - return - } - latestStore.setSshTargetsMetadata(targets) - applySshConnectionStateChange(data.targetId, state) - }) - .catch(() => { - if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) { - latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state) - } - }) + unknownSshTargetStates.enqueue(data.targetId, state) return } - latestSshTargetStateEventByTargetId.delete(data.targetId) + unknownSshTargetStates.forget(data.targetId) applySshConnectionStateChange(data.targetId, state) } @@ -2922,15 +2902,8 @@ export function useIpcEvents(): void { }, PENDING_AGENT_STATUS_RETRY_MS) } - function enqueuePendingAgentStatus( - data: AgentStatusIpcPayload, - options?: { replay?: boolean } - ): void { - pendingAgentStatusEvents.push({ - data, - firstSeenAt: Date.now(), - replay: options?.replay === true - }) + function enqueuePendingAgentStatus(data: AgentStatusIpcPayload): void { + pendingAgentStatusEvents.push({ data, firstSeenAt: Date.now() }) while (pendingAgentStatusEvents.length > MAX_PENDING_AGENT_STATUS_EVENTS) { pendingAgentStatusEvents.shift() } @@ -2953,7 +2926,7 @@ export function useIpcEvents(): void { if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) { continue } - const result = applyAgentStatus(event.data, { retry: true, replay: event.replay }) + const result = applyAgentStatus(event.data, { retry: true }) if (result === 'pending') { remaining.push(event) } @@ -3020,27 +2993,17 @@ export function useIpcEvents(): void { } } if (!exists) { - // Why: startup snapshot replay can beat tab/layout hydration too. - // Reuse the same bounded retry queue when the row still carries - // runtime-backed worktree provenance so the cached status can adopt - // once the pane becomes visible. - if (options?.replay === true) { - if (data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { - if (options?.retry !== true) { - enqueuePendingAgentStatus(data, { replay: true }) - } - return 'pending' + // Why: a non-empty paneKey with no matching tab is a routing failure to track. + // Skip during replay — main's durable cache legitimately holds closed-tab entries. + if (options?.replay !== true) { + if (options?.retry !== true) { + track('agent_hook_unattributed', { reason: 'unknown_tab_id' }) + // Why: live hook IPC can beat tab/layout hydration; retry so a transient pane-key miss doesn't drop completion state. + enqueuePendingAgentStatus(data) } - return 'dropped' + return 'pending' } - if (options?.retry !== true) { - // Why: empty paneKeys are dropped in main before IPC fanout. Reaching - // this branch means a non-empty paneKey escaped without a matching - // renderer tab, so track the adoption/routing failure separately. - track('agent_hook_unattributed', { reason: 'unknown_tab_id' }) - enqueuePendingAgentStatus(data) - } - return 'pending' + return 'dropped' } if (options?.replay !== true && options?.retry !== true) { for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { @@ -3057,7 +3020,7 @@ export function useIpcEvents(): void { : data.connectionId const transientClearWatermark = typeof data.connectionId === 'string' - ? transientClearWatermarkByConnectionId.get(data.connectionId) + ? transientClearWatermarks.get(data.connectionId) : undefined // Why: delayed snapshots/queued relay events must not resurrect a status cleared by a newer disconnect on this connection. if (transientClearWatermark !== undefined && data.receivedAt <= transientClearWatermark) { @@ -3253,10 +3216,13 @@ export function useIpcEvents(): void { ) { return } - const previousWatermark = - transientClearWatermarkByConnectionId.get(data.connectionId) ?? -1 - const effectiveWatermark = Math.max(previousWatermark, data.clearedAt) - transientClearWatermarkByConnectionId.set(data.connectionId, effectiveWatermark) + const effectiveWatermark = transientClearWatermarks.remember( + data.connectionId, + data.clearedAt + ) + if (effectiveWatermark === null) { + return + } for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { const pending = pendingAgentStatusEvents[index].data if ( diff --git a/src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx b/src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx new file mode 100644 index 00000000000..f4a4f317378 --- /dev/null +++ b/src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment happy-dom + +// Why: the sibling hook test mocks @/lib/primary-selection, so it cannot prove +// the armed window is really single-shot. This file wires the hook to the real +// module and exercises the Linux terminal middle-click follow-up end to end. + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + armPrimarySelectionNativePasteSuppression, + resetPrimarySelectionForTests +} from '@/lib/primary-selection' +import { usePrimarySelectionPaste } from './usePrimarySelectionPaste' + +const originalUserAgent = navigator.userAgent + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function Probe(): null { + usePrimarySelectionPaste(true) + return null +} + +function setUserAgent(userAgent: string): void { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent }) +} + +async function renderProbe(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +// Stand-in for xterm's hidden helper textarea inside its `.xterm` container. +function appendXtermHelperTextarea(): HTMLTextAreaElement { + const terminal = document.createElement('div') + terminal.className = 'xterm' + const textarea = document.createElement('textarea') + textarea.className = 'xterm-helper-textarea' + terminal.appendChild(textarea) + document.body.appendChild(terminal) + return textarea +} + +function dispatchPasteBeforeInput(target: HTMLElement): Event { + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + data: 'pasted', + inputType: 'insertFromPaste' + }) + target.dispatchEvent(event) + return event +} + +// Why: `paste` is the event that actually reaches the PTY — xterm forwards from +// a `paste` listener and never listens to `beforeinput`. +function dispatchClipboardPaste(target: HTMLElement): Event { + const event = new Event('paste', { bubbles: true, cancelable: true }) + target.dispatchEvent(event) + return event +} + +beforeEach(() => { + resetPrimarySelectionForTests() + setUserAgent('Mozilla/5.0 (X11; Linux x86_64)') +}) + +afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + document.body.replaceChildren() + setUserAgent(originalUserAgent) + resetPrimarySelectionForTests() +}) + +describe('terminal-armed native paste suppression', () => { + it('swallows the first follow-up paste but not a later real paste in the same window', async () => { + await renderProbe() + const terminalTextarea = appendXtermHelperTextarea() + armPrimarySelectionNativePasteSuppression() + let nativeFollowUp!: Event + let keyboardPaste!: Event + + await act(async () => { + nativeFollowUp = dispatchPasteBeforeInput(terminalTextarea) + keyboardPaste = dispatchPasteBeforeInput(terminalTextarea) + }) + + expect(nativeFollowUp.defaultPrevented).toBe(true) + expect(keyboardPaste.defaultPrevented).toBe(false) + }) + + it('swallows one clipboard paste per arm, the event xterm forwards to the PTY', async () => { + await renderProbe() + const terminalTextarea = appendXtermHelperTextarea() + armPrimarySelectionNativePasteSuppression() + let nativeFollowUp!: Event + let keyboardPaste!: Event + + await act(async () => { + nativeFollowUp = dispatchClipboardPaste(terminalTextarea) + keyboardPaste = dispatchClipboardPaste(terminalTextarea) + }) + + expect(nativeFollowUp.defaultPrevented).toBe(true) + expect(keyboardPaste.defaultPrevented).toBe(false) + }) + + it('keeps suppressing nothing when the terminal never armed the window', async () => { + await renderProbe() + const terminalTextarea = appendXtermHelperTextarea() + + let paste!: Event + await act(async () => { + paste = dispatchPasteBeforeInput(terminalTextarea) + }) + + expect(paste.defaultPrevented).toBe(false) + }) + + it('does not arm on macOS, where Chromium emits no native follow-up paste', async () => { + setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)') + await renderProbe() + const terminalTextarea = appendXtermHelperTextarea() + armPrimarySelectionNativePasteSuppression() + + let paste!: Event + await act(async () => { + paste = dispatchPasteBeforeInput(terminalTextarea) + }) + + expect(paste.defaultPrevented).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx b/src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx index f2128041b08..5724283ae1c 100644 --- a/src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx +++ b/src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx @@ -4,21 +4,21 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - readPrimarySelectionText, - shouldSuppressPrimarySelectionNativePaste + consumePrimarySelectionNativePasteSuppression, + readPrimarySelectionText } from '@/lib/primary-selection' import { usePrimarySelectionPaste } from './usePrimarySelectionPaste' vi.mock('@/lib/primary-selection', () => ({ + consumePrimarySelectionNativePasteSuppression: vi.fn(() => false), readPrimarySelectionText: vi.fn(), setPrimarySelectionEnabled: vi.fn(), - setPrimarySelectionText: vi.fn(), - shouldSuppressPrimarySelectionNativePaste: vi.fn(() => false) + setPrimarySelectionText: vi.fn() })) const originalUserAgent = navigator.userAgent const readPrimarySelectionTextMock = vi.mocked(readPrimarySelectionText) -const shouldSuppressNativePasteMock = vi.mocked(shouldSuppressPrimarySelectionNativePaste) +const consumeNativePasteMock = vi.mocked(consumePrimarySelectionNativePasteSuppression) let root: Root | null = null let container: HTMLDivElement | null = null @@ -123,7 +123,7 @@ async function renderProbe(): Promise { beforeEach(() => { setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)') - shouldSuppressNativePasteMock.mockReturnValue(false) + consumeNativePasteMock.mockReturnValue(false) }) afterEach(async () => { @@ -211,7 +211,7 @@ describe('usePrimarySelectionPaste', () => { it('swallows terminal-armed native paste follow-up even when it has no pending DOM target', async () => { setUserAgent('Mozilla/5.0 (X11; Linux x86_64)') - shouldSuppressNativePasteMock.mockReturnValue(true) + consumeNativePasteMock.mockReturnValue(true) await renderProbe() // Stand-in for xterm's helper textarea, which owns its own middle-click // paste and never registers a pending primary-selection DOM target. @@ -230,11 +230,32 @@ describe('usePrimarySelectionPaste', () => { expect(readPrimarySelectionTextMock).not.toHaveBeenCalled() }) + it('re-asks per paste event instead of caching the first suppression answer', async () => { + setUserAgent('Mozilla/5.0 (X11; Linux x86_64)') + // Single-shot lives in the module, which is mocked here; this only pins that + // the hook asks once per event, so the real one-shot answer is honored. + consumeNativePasteMock.mockReturnValueOnce(true).mockReturnValue(false) + await renderProbe() + const terminalTextarea = appendXtermHelperTextarea() + let nativeFollowUp!: Event + let keyboardPaste!: Event + + await act(async () => { + nativeFollowUp = dispatchNativePasteBeforeInput(terminalTextarea) + keyboardPaste = dispatchNativePasteBeforeInput(terminalTextarea) + await flushPromises() + }) + + expect(nativeFollowUp.defaultPrevented).toBe(true) + expect(keyboardPaste.defaultPrevented).toBe(false) + expect(consumeNativePasteMock).toHaveBeenCalledTimes(2) + }) + it('does not suppress an unrelated document paste while the terminal window is armed', async () => { setUserAgent('Mozilla/5.0 (X11; Linux x86_64)') // Armed window is active, but the paste targets a control outside the // terminal (e.g. right-click Paste into a form field) and must survive. - shouldSuppressNativePasteMock.mockReturnValue(true) + consumeNativePasteMock.mockReturnValue(true) await renderProbe() const unrelatedTextarea = appendTextarea() let nativeBeforeInput!: Event @@ -248,11 +269,14 @@ describe('usePrimarySelectionPaste', () => { expect(nativeBeforeInput.defaultPrevented).toBe(false) expect(nativePaste.defaultPrevented).toBe(false) + // Consuming mutates, so the target check must short-circuit first: an + // unrelated paste that burned the arm would let the follow-up double-paste. + expect(consumeNativePasteMock).not.toHaveBeenCalled() }) it('does not suppress native paste when the terminal has not armed the window', async () => { setUserAgent('Mozilla/5.0 (X11; Linux x86_64)') - shouldSuppressNativePasteMock.mockReturnValue(false) + consumeNativePasteMock.mockReturnValue(false) await renderProbe() const textarea = appendTextarea() diff --git a/src/renderer/src/hooks/usePrimarySelectionPaste.ts b/src/renderer/src/hooks/usePrimarySelectionPaste.ts index 0c98d0822e2..03287ca7473 100644 --- a/src/renderer/src/hooks/usePrimarySelectionPaste.ts +++ b/src/renderer/src/hooks/usePrimarySelectionPaste.ts @@ -1,10 +1,10 @@ import { useEffect } from 'react' import { isLinuxUserAgent, isMacUserAgent } from '@/components/terminal-pane/pane-helpers' import { + consumePrimarySelectionNativePasteSuppression, readPrimarySelectionText, setPrimarySelectionEnabled, - setPrimarySelectionText, - shouldSuppressPrimarySelectionNativePaste + setPrimarySelectionText } from '@/lib/primary-selection' import { findEditablePrimarySelectionPasteTarget, @@ -110,9 +110,10 @@ export function usePrimarySelectionPaste(enabled: boolean): void { // a pending DOM target, so honor its armed window to swallow the follow-up // native paste event that xterm would otherwise forward to the PTY — but // only for the terminal's own surface, never unrelated document pastes. + // Consuming leaves the window disarmed so a later real paste survives. if ( isTerminalNativePasteTarget(event.target) && - shouldSuppressPrimarySelectionNativePaste() + consumePrimarySelectionNativePasteSuppression() ) { suppressEvent(event) } diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts index 7c04b18cfca..10374138988 100644 --- a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts @@ -198,4 +198,56 @@ describe('createWorktreeChangeRefreshQueue', () => { expect(handler).not.toHaveBeenCalled() }) + + it('bounds a stalled repo queue and converges with one full refresh after overflow', async () => { + const firstRefresh = deferred() + const handler = vi.fn().mockReturnValueOnce(firstRefresh.promise).mockResolvedValue(undefined) + const queue = createWorktreeChangeRefreshQueue(handler, { + maxRepoStates: 2, + maxQueuedPerRepo: 2, + maxQueuedTotal: 2 + }) + const firstRename = { oldWorktreeId: 'wt-1', newWorktreeId: 'wt-2' } + const secondRename = { oldWorktreeId: 'wt-2', newWorktreeId: 'wt-3' } + + queue.enqueue({ repoId: 'repo-1' }) + queue.enqueue({ repoId: 'repo-1', renamed: firstRename }) + queue.enqueue({ repoId: 'repo-1', renamed: secondRename }) + for (let index = 0; index < 100; index++) { + queue.enqueue({ + repoId: 'repo-1', + renamed: { oldWorktreeId: `overflow-${index}`, newWorktreeId: `next-${index}` } + }) + } + + firstRefresh.resolve() + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(4)) + + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', firstRename, { + forceLocalOwner: undefined + }) + expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', secondRename, { + forceLocalOwner: undefined + }) + expect(handler).toHaveBeenNthCalledWith(4, 'repo-1', undefined, { + forceLocalOwner: undefined + }) + }) + + it('bounds repo records while refresh handlers are stalled', () => { + const stalled = deferred() + const handler = vi.fn(() => stalled.promise) + const queue = createWorktreeChangeRefreshQueue(handler, { + maxRepoStates: 1, + maxQueuedPerRepo: 2, + maxQueuedTotal: 2 + }) + + queue.enqueue({ repoId: 'repo-1' }) + queue.enqueue({ repoId: 'repo-2' }) + + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith('repo-1', undefined, { forceLocalOwner: undefined }) + queue.dispose() + }) }) diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.ts index ef69da6dd05..c0ef5522ea5 100644 --- a/src/renderer/src/hooks/worktree-change-refresh-queue.ts +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.ts @@ -25,26 +25,56 @@ type QueuedWorktreeChange = { type RepoRefreshState = { running: boolean queue: QueuedWorktreeChange[] + overflowedDefault: boolean + overflowedLocal: boolean } +export const WORKTREE_REFRESH_MAX_REPO_STATES = 1024 +export const WORKTREE_REFRESH_MAX_QUEUED_PER_REPO = 4096 +export const WORKTREE_REFRESH_MAX_QUEUED_TOTAL = 16_384 + export type WorktreeChangeRefreshQueue = { dispose: () => void enqueue: (event: WorktreeChangeEvent) => void } export function createWorktreeChangeRefreshQueue( - handler: WorktreeChangeRefreshHandler + handler: WorktreeChangeRefreshHandler, + options?: { + maxRepoStates?: number + maxQueuedPerRepo?: number + maxQueuedTotal?: number + } ): WorktreeChangeRefreshQueue { const states = new Map() + const maxRepoStates = options?.maxRepoStates ?? WORKTREE_REFRESH_MAX_REPO_STATES + const maxQueuedPerRepo = options?.maxQueuedPerRepo ?? WORKTREE_REFRESH_MAX_QUEUED_PER_REPO + const maxQueuedTotal = options?.maxQueuedTotal ?? WORKTREE_REFRESH_MAX_QUEUED_TOTAL + let queuedCount = 0 let disposed = false const drain = async (repoId: string, state: RepoRefreshState): Promise => { state.running = true try { - while (!disposed && state.queue.length > 0) { + while ( + !disposed && + (state.queue.length > 0 || state.overflowedDefault || state.overflowedLocal) + ) { const next = state.queue.shift() + let overflowForceLocalOwner: boolean | undefined + if (next) { + queuedCount = Math.max(0, queuedCount - 1) + } else if (state.overflowedDefault) { + state.overflowedDefault = false + } else { + state.overflowedLocal = false + overflowForceLocalOwner = true + } try { - await handler(repoId, next?.renamed, { forceLocalOwner: next?.forceLocalOwner }) + // Why: one full refresh per owner route converges changes shed during extreme bursts. + await handler(repoId, next?.renamed, { + forceLocalOwner: next?.forceLocalOwner ?? overflowForceLocalOwner + }) } catch (error) { console.error('Failed to refresh changed worktrees:', error) } @@ -63,6 +93,7 @@ export function createWorktreeChangeRefreshQueue( dispose() { disposed = true states.clear() + queuedCount = 0 }, enqueue(event) { @@ -71,12 +102,33 @@ export function createWorktreeChangeRefreshQueue( } let state = states.get(event.repoId) if (!state) { - state = { running: false, queue: [] } + if (states.size >= maxRepoStates) { + return + } + state = { + running: false, + queue: [], + overflowedDefault: false, + overflowedLocal: false + } states.set(event.repoId, state) } + if (state.queue.length >= maxQueuedPerRepo || queuedCount >= maxQueuedTotal) { + if (event.forceLocalOwner) { + state.overflowedLocal = true + } else { + state.overflowedDefault = true + } + if (!state.running) { + void drain(event.repoId, state) + } + return + } + if (event.renamed) { state.queue.push({ renamed: event.renamed, forceLocalOwner: event.forceLocalOwner }) + queuedCount += 1 } else { const lastQueued = state.queue.at(-1) // Why: Windows/OneDrive can emit a burst for one checkout change. Keep a @@ -89,6 +141,7 @@ export function createWorktreeChangeRefreshQueue( Boolean(lastQueued.forceLocalOwner) !== Boolean(event.forceLocalOwner) ) { state.queue.push({ forceLocalOwner: event.forceLocalOwner }) + queuedCount += 1 } } diff --git a/src/renderer/src/hooks/worktree-rename-grace-retention.test.ts b/src/renderer/src/hooks/worktree-rename-grace-retention.test.ts new file mode 100644 index 00000000000..9b1e6c160a9 --- /dev/null +++ b/src/renderer/src/hooks/worktree-rename-grace-retention.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WorktreeRenameGraceRetention } from './worktree-rename-grace-retention' + +describe('worktree rename grace retention', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1000) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('sweeps expired IDs even when no async refresh completes', async () => { + const retention = new WorktreeRenameGraceRetention() + retention.remember(['old-id', 'new-id'], 2000) + + expect(retention.evidence().entries).toBe(2) + await vi.advanceTimersByTimeAsync(1000) + expect(retention.evidence()).toEqual({ entries: 0, idBytes: 0 }) + retention.dispose() + }) + + it('bounds retained IDs and suppresses destructive purges during overflow', () => { + const retention = new WorktreeRenameGraceRetention({ + maxEntries: 2, + maxIdBytes: 8, + maxTotalIdBytes: 16 + }) + + retention.remember(['old-a', 'new-a', 'overflow'], 2000) + + expect(retention.evidence()).toEqual({ + entries: 2, + idBytes: 10, + suppressAllUntil: 2000 + }) + expect(retention.protects('untracked', 1500)).toBe(true) + expect(retention.protects('untracked', 2000)).toBe(false) + retention.dispose() + }) + + it('does not retain an oversized worktree ID', () => { + const retention = new WorktreeRenameGraceRetention({ + maxEntries: 2, + maxIdBytes: 4, + maxTotalIdBytes: 8 + }) + + retention.remember(['oversized'], 2000) + + expect(retention.evidence()).toEqual({ + entries: 0, + idBytes: 0, + suppressAllUntil: 2000 + }) + retention.dispose() + }) +}) diff --git a/src/renderer/src/hooks/worktree-rename-grace-retention.ts b/src/renderer/src/hooks/worktree-rename-grace-retention.ts new file mode 100644 index 00000000000..1f86ad02cb3 --- /dev/null +++ b/src/renderer/src/hooks/worktree-rename-grace-retention.ts @@ -0,0 +1,133 @@ +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' + +export const WORKTREE_RENAME_GRACE_MAX_ENTRIES = 4096 +export const WORKTREE_RENAME_GRACE_MAX_ID_UTF8_BYTES = 64 * 1024 +export const WORKTREE_RENAME_GRACE_MAX_TOTAL_ID_UTF8_BYTES = 8 * 1024 * 1024 + +type RetainedRenameId = { + expiresAt: number + idBytes: number +} + +type WorktreeRenameGraceBounds = { + maxEntries: number + maxIdBytes: number + maxTotalIdBytes: number +} + +const DEFAULT_BOUNDS: WorktreeRenameGraceBounds = { + maxEntries: WORKTREE_RENAME_GRACE_MAX_ENTRIES, + maxIdBytes: WORKTREE_RENAME_GRACE_MAX_ID_UTF8_BYTES, + maxTotalIdBytes: WORKTREE_RENAME_GRACE_MAX_TOTAL_ID_UTF8_BYTES +} + +export class WorktreeRenameGraceRetention { + private readonly entries = new Map() + private retainedIdBytes = 0 + private suppressAllUntil = Number.NEGATIVE_INFINITY + private sweepTimer: ReturnType | null = null + private sweepDueAt = Number.POSITIVE_INFINITY + + constructor( + private readonly bounds: WorktreeRenameGraceBounds = DEFAULT_BOUNDS, + private readonly now: () => number = Date.now + ) {} + + remember(ids: readonly string[], expiresAt: number): void { + const now = this.now() + this.sweepExpired(now) + if (!Number.isFinite(expiresAt) || expiresAt <= now) { + return + } + for (const id of ids) { + const measured = measureUtf8ByteLength(id, { + stopAfterBytes: this.bounds.maxIdBytes + }) + const previous = this.entries.get(id) + if (previous) { + previous.expiresAt = Math.max(previous.expiresAt, expiresAt) + continue + } + if ( + id.length === 0 || + measured.exceededLimit || + this.entries.size >= this.bounds.maxEntries || + this.retainedIdBytes + measured.byteLength > this.bounds.maxTotalIdBytes + ) { + // Why: overload must defer destructive purge rather than misclassify an unretained rename as deletion. + this.suppressAllUntil = Math.max(this.suppressAllUntil, expiresAt) + continue + } + this.entries.set(id, { expiresAt, idBytes: measured.byteLength }) + this.retainedIdBytes += measured.byteLength + } + this.scheduleSweep() + } + + protects(id: string, now = this.now()): boolean { + this.sweepExpired(now) + if (this.suppressAllUntil > now) { + return true + } + return (this.entries.get(id)?.expiresAt ?? Number.NEGATIVE_INFINITY) > now + } + + evidence(): { entries: number; idBytes: number; suppressAllUntil?: number } { + return { + entries: this.entries.size, + idBytes: this.retainedIdBytes, + ...(Number.isFinite(this.suppressAllUntil) ? { suppressAllUntil: this.suppressAllUntil } : {}) + } + } + + dispose(): void { + if (this.sweepTimer !== null) { + clearTimeout(this.sweepTimer) + } + this.sweepTimer = null + this.sweepDueAt = Number.POSITIVE_INFINITY + this.entries.clear() + this.retainedIdBytes = 0 + this.suppressAllUntil = Number.NEGATIVE_INFINITY + } + + private sweepExpired(now: number): void { + for (const [id, entry] of this.entries) { + if (entry.expiresAt > now) { + continue + } + this.entries.delete(id) + this.retainedIdBytes -= entry.idBytes + } + if (this.suppressAllUntil <= now) { + this.suppressAllUntil = Number.NEGATIVE_INFINITY + } + } + + private scheduleSweep(): void { + let dueAt = Number.isFinite(this.suppressAllUntil) + ? this.suppressAllUntil + : Number.POSITIVE_INFINITY + for (const entry of this.entries.values()) { + dueAt = Math.min(dueAt, entry.expiresAt) + } + if (!Number.isFinite(dueAt) || (this.sweepTimer !== null && this.sweepDueAt <= dueAt)) { + return + } + if (this.sweepTimer !== null) { + clearTimeout(this.sweepTimer) + } + this.sweepDueAt = dueAt + this.sweepTimer = setTimeout( + () => { + this.sweepTimer = null + this.sweepDueAt = Number.POSITIVE_INFINITY + this.sweepExpired(this.now()) + this.scheduleSweep() + }, + Math.max(0, dueAt - this.now()) + ) + } +} + +export const recentlyRenamedWorktreeGrace = new WorktreeRenameGraceRetention() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3e9cb3c6261..015e5f37fa5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2669,9 +2669,18 @@ "blocked": { "toast": { "97c98f1afe": "Open Setting", - "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.", + "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, Zellij, tmux, Neovim, fzf, or Grok.", "89eaa3e80b": "Terminal clipboard write blocked" } + }, + "default": { + "on": { + "notice": { + "title": "TUI clipboard writes are now on by default", + "description": "Zellij, tmux, Neovim and other terminal programs can now copy to your clipboard. Turn it off in Terminal settings.", + "action": "Open Setting" + } + } } } }, @@ -3680,7 +3689,8 @@ "SourceControlActionVariableChips": { "1b77798d5f": "Variables", "6b921a0ac2": "Example", - "4bf6d88039": "(empty)" + "4bf6d88039": "(empty)", + "7377483644": "This workspace" } } }, @@ -7002,9 +7012,9 @@ "d23b43c5be": "Setup Script Location", "34a0dfa06e": "Where the repository setup script runs when a new workspace is created.", "21f8da2078": "Workspace Setup Script", - "6e6480a7df": "Let programs in the terminal (Grok, tmux, Neovim, fzf, SSH) copy to your system clipboard.", + "6e6480a7df": "Let programs in the terminal (Zellij, tmux, Neovim, fzf, Grok, SSH) copy to your system clipboard.", "3338dcf8c1": "Allow TUI Clipboard Writes (OSC 52)", - "69c64a479c": "Let Grok, tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).", + "69c64a479c": "Let Zellij, tmux, Neovim, fzf, and Grok copy to the system clipboard over the PTY (including over SSH).", "4729c645fc": "Automatically copy terminal selections to the clipboard.", "902f5dee1f": "Copy on Select", "9129b7e805": "Hovering a terminal pane activates it without needing to click.", @@ -7702,7 +7712,8 @@ "78a10b826f": "bonjour", "e3fbc48083": "bluetooth", "c4a4a02ea4": "usb", - "fa3239cd42": "lan", + "fa3239cd42": "local network", + "87620e6416": "lan", "acad3d4743": "Allow device tools and LAN access used from terminal sessions.", "3e0131e45d": "icloud", "ce07159ff5": "desktop", @@ -8641,7 +8652,6 @@ "10f9fb6fea": "settings", "2ade3ea490": "config", "fd752b3cac": "import", - "82b63d07fe": "ghostty", "73e9422f19": "One-time import of supported Ghostty terminal settings.", "a979df0083": "Import from Ghostty", "warp_import": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7d50932493a..643c084310f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2646,9 +2646,18 @@ "blocked": { "toast": { "97c98f1afe": "Abrir ajustes", - "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim, fzf o Grok.", + "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, Zellij, tmux, Neovim, fzf o Grok.", "89eaa3e80b": "Escritura al portapapeles del terminal bloqueada" } + }, + "default": { + "on": { + "notice": { + "title": "La escritura del portapapeles desde TUI ahora está activada de forma predeterminada", + "description": "Zellij, tmux, Neovim y otros programas de terminal ya pueden copiar a tu portapapeles. Desactívalo en la configuración de Terminal.", + "action": "Abrir ajuste" + } + } } } }, @@ -3657,7 +3666,8 @@ "SourceControlActionVariableChips": { "1b77798d5f": "Variables", "6b921a0ac2": "Ejemplo", - "4bf6d88039": "(vacío)" + "4bf6d88039": "(vacío)", + "7377483644": "Este espacio de trabajo" } } }, @@ -6942,9 +6952,9 @@ "d23b43c5be": "Ubicación del script de configuración", "34a0dfa06e": "Dónde se ejecuta el script de configuración del repositorio cuando se crea un nuevo espacio de trabajo.", "21f8da2078": "Script de configuración del espacio de trabajo", - "6e6480a7df": "Permite que los programas del terminal (Grok, tmux, Neovim, fzf, SSH) copien al portapapeles del sistema.", + "6e6480a7df": "Permite que los programas del terminal (Zellij, tmux, Neovim, fzf, Grok, SSH) copien al portapapeles del sistema.", "3338dcf8c1": "Permitir escrituras en el portapapeles TUI (OSC 52)", - "69c64a479c": "Permite que Grok, tmux, Neovim y fzf copien al portapapeles del sistema a través de PTY (incluso por SSH).", + "69c64a479c": "Permite que Zellij, tmux, Neovim, fzf y Grok copien al portapapeles del sistema a través de PTY (incluso por SSH).", "4729c645fc": "Copia automáticamente las selecciones del terminal al portapapeles.", "902f5dee1f": "Copiar al seleccionar", "9129b7e805": "Al pasar el cursor sobre un panel de terminal, se activa sin necesidad de hacer clic.", @@ -7643,6 +7653,7 @@ "e3fbc48083": "bluetooth", "c4a4a02ea4": "USB", "fa3239cd42": "red local", + "87620e6416": "LAN", "acad3d4743": "Permitir herramientas de dispositivo y acceso LAN desde sesiones de terminal.", "3e0131e45d": "iCloud", "ce07159ff5": "escritorio", @@ -8581,7 +8592,6 @@ "10f9fb6fea": "ajustes", "2ade3ea490": "configuración", "fd752b3cac": "importar", - "82b63d07fe": "ghostty", "73e9422f19": "Importación única de ajustes de terminal compatibles de Ghostty.", "a979df0083": "Importar desde Ghostty", "4cec42dbf7": "internacional", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 53797ce8291..24b9d9a871b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2646,9 +2646,18 @@ "blocked": { "toast": { "97c98f1afe": "設定を開く", - "7cf51f74fd": "SSH、tmux、Neovim、fzf、または Grok からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", + "7cf51f74fd": "SSH、Zellij、tmux、Neovim、fzf、または Grok からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", "89eaa3e80b": "Terminal のクリップボードへの書き込みがブロックされました" } + }, + "default": { + "on": { + "notice": { + "title": "TUI からのクリップボードへの書き込みがデフォルトで有効になりました", + "description": "Zellij、tmux、Neovim などのターミナルプログラムがクリップボードにコピーできるようになりました。Terminal 設定で無効にできます。", + "action": "設定を開く" + } + } } } }, @@ -3657,7 +3666,8 @@ "SourceControlActionVariableChips": { "1b77798d5f": "変数", "6b921a0ac2": "例", - "4bf6d88039": "(空の)" + "4bf6d88039": "(空の)", + "7377483644": "このワークスペース" } } }, @@ -6964,9 +6974,9 @@ "d23b43c5be": "セットアップスクリプトの場所", "34a0dfa06e": "新規ワークスペースの作成時に repos セットアップ スクリプトが実行される場所。", "21f8da2078": "ワークスペースセットアップスクリプト", - "6e6480a7df": "terminal 内のプログラム (Grok、tmux、Neovim、fzf、SSH) をシステムのクリップボードにコピーします。", + "6e6480a7df": "terminal 内のプログラム (Zellij、tmux、Neovim、fzf、Grok、SSH) をシステムのクリップボードにコピーします。", "3338dcf8c1": "TUI クリップボードへの書き込みを許可する (OSC 52)", - "69c64a479c": "Grok、tmux、Neovim、および fzf が PTY 経由 (SSH 経由を含む) でシステム クリップボードにコピーできるようにします。", + "69c64a479c": "Zellij、tmux、Neovim、fzf、および Grok が PTY 経由 (SSH 経由を含む) でシステム クリップボードにコピーできるようにします。", "4729c645fc": "terminal の選択内容をクリップボードに自動的にコピーします。", "902f5dee1f": "選択時にコピー", "9129b7e805": "terminal ペインにマウスを移動すると、クリックしなくても terminal ペインがアクティブになります。", @@ -7665,6 +7675,7 @@ "e3fbc48083": "ブルートゥース", "c4a4a02ea4": "USB", "fa3239cd42": "ローカルネットワーク", + "87620e6416": "LAN", "acad3d4743": "terminal セッションから使用されるデバイス ツールと LAN アクセスを許可します。", "3e0131e45d": "icloud", "ce07159ff5": "デスクトップ", @@ -8603,7 +8614,6 @@ "10f9fb6fea": "設定", "2ade3ea490": "構成", "fd752b3cac": "インポート", - "82b63d07fe": "ghostty", "73e9422f19": "サポートされている Ghostty terminal 設定の 1 回限りのインポート。", "a979df0083": "Ghostty からインポート", "warp_import": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index dd9832c808c..d77d2f79d9f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2646,9 +2646,18 @@ "blocked": { "toast": { "97c98f1afe": "설정 열기", - "7cf51f74fd": "SSH, tmux, Neovim, fzf 또는 Grok에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", + "7cf51f74fd": "SSH, Zellij, tmux, Neovim, fzf 또는 Grok에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", "89eaa3e80b": "Terminal 클립보드 쓰기가 차단되었습니다." } + }, + "default": { + "on": { + "notice": { + "title": "TUI 클립보드 쓰기가 이제 기본으로 켜져 있습니다", + "description": "Zellij, tmux, Neovim 등 터미널 프로그램이 클립보드에 복사할 수 있습니다. Terminal 설정에서 끌 수 있습니다.", + "action": "설정 열기" + } + } } } }, @@ -3657,7 +3666,8 @@ "SourceControlActionVariableChips": { "1b77798d5f": "변수", "6b921a0ac2": "예", - "4bf6d88039": "(비어 있음)" + "4bf6d88039": "(비어 있음)", + "7377483644": "이 워크스페이스" } } }, @@ -6927,9 +6937,9 @@ "d23b43c5be": "설정 스크립트 위치", "34a0dfa06e": "새 워크스페이스를 만들 때 repos 설정 스크립트를 실행할 위치입니다.", "21f8da2078": "워크스페이스 설정 스크립트", - "6e6480a7df": "terminal의 프로그램(Grok, tmux, Neovim, fzf, SSH)이 시스템 클립보드에 복사할 수 있게 합니다.", + "6e6480a7df": "terminal의 프로그램(Zellij, tmux, Neovim, fzf, Grok, SSH)이 시스템 클립보드에 복사할 수 있게 합니다.", "3338dcf8c1": "TUI 클립보드 쓰기 허용(OSC 52)", - "69c64a479c": "Grok, tmux, Neovim 및 fzf가 PTY(SSH 포함)를 통해 시스템 클립보드에 복사하도록 합니다.", + "69c64a479c": "Zellij, tmux, Neovim, fzf 및 Grok이 PTY(SSH 포함)를 통해 시스템 클립보드에 복사하도록 합니다.", "4729c645fc": "terminal 선택 사항을 클립보드에 자동으로 복사합니다.", "902f5dee1f": "선택 시 복사", "9129b7e805": "terminal 패널에 마우스를 가리키면 클릭하지 않고도 활성화됩니다.", @@ -7628,6 +7638,7 @@ "e3fbc48083": "블루투스", "c4a4a02ea4": "USB", "fa3239cd42": "로컬 네트워크", + "87620e6416": "LAN", "acad3d4743": "terminal 세션에서 사용되는 장치 도구와 LAN 액세스를 허용합니다.", "3e0131e45d": "icloud", "ce07159ff5": "데스크탑", @@ -8566,7 +8577,6 @@ "10f9fb6fea": "설정", "2ade3ea490": "구성", "fd752b3cac": "가져오기", - "82b63d07fe": "ghostty", "73e9422f19": "지원되는 Ghostty terminal 설정을 한 번만 가져옵니다.", "a979df0083": "Ghostty에서 가져오기", "4cec42dbf7": "국제", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0d77a72802d..28e8c153460 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2646,9 +2646,18 @@ "blocked": { "toast": { "97c98f1afe": "打开设置", - "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim、fzf 或 Grok 进行复制。", + "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、Zellij、tmux、Neovim、fzf 或 Grok 进行复制。", "89eaa3e80b": "终端剪贴板写入被阻止" } + }, + "default": { + "on": { + "notice": { + "title": "TUI 剪贴板写入现已默认开启", + "description": "Zellij、tmux、Neovim 等终端程序现在可以复制到你的剪贴板。可在 Terminal 设置中关闭。", + "action": "打开设置" + } + } } } }, @@ -3657,7 +3666,8 @@ "SourceControlActionVariableChips": { "1b77798d5f": "变量", "6b921a0ac2": "例子", - "4bf6d88039": "(空的)" + "4bf6d88039": "(空的)", + "7377483644": "此工作区" } } }, @@ -6870,7 +6880,7 @@ "4aae5db258": "字体粗细", "f04b17a50e": "新窗格和实时更新的默认终端字体系列。", "a408266e67": "字体家族", - "855a76343a": "从幽灵导入", + "855a76343a": "从 Ghostty 导入", "711e589f18": "新窗格和实时更新的默认终端排版。", "048aac8a64": "终端排版", "4415beb958": "已禁用", @@ -6927,9 +6937,9 @@ "d23b43c5be": "安装脚本位置", "34a0dfa06e": "创建新工作区时运行存储库设置脚本的位置。", "21f8da2078": "工作区设置脚本", - "6e6480a7df": "让终端中的程序(Grok、tmux、Neovim、fzf、SSH)复制到系统剪贴板。", + "6e6480a7df": "让终端中的程序(Zellij、tmux、Neovim、fzf、Grok、SSH)复制到系统剪贴板。", "3338dcf8c1": "允许 TUI 剪贴板写入 (OSC 52)", - "69c64a479c": "让 Grok、tmux、Neovim 和 fzf 通过 PTY(包括通过 SSH)复制到系统剪贴板。", + "69c64a479c": "让 Zellij、tmux、Neovim、fzf 和 Grok 通过 PTY(包括通过 SSH)复制到系统剪贴板。", "4729c645fc": "自动将终端选择复制到剪贴板。", "902f5dee1f": "选择时复制", "9129b7e805": "将鼠标悬停在终端窗格上即可将其激活,无需单击。", @@ -7627,7 +7637,8 @@ "78a10b826f": "你好", "e3fbc48083": "蓝牙", "c4a4a02ea4": "USB", - "fa3239cd42": "局域网", + "fa3239cd42": "本地网络", + "87620e6416": "局域网", "acad3d4743": "允许从终端会话使用设备工具和局域网访问。", "3e0131e45d": "云", "ce07159ff5": "桌面", @@ -8566,9 +8577,8 @@ "10f9fb6fea": "设置", "2ade3ea490": "配置", "fd752b3cac": "导入", - "82b63d07fe": "Ghostty", "73e9422f19": "一次性导入受支持的 Ghostty 终端设置。", - "a979df0083": "从幽灵导入", + "a979df0083": "从 Ghostty 导入", "4cec42dbf7": "国际", "b495dc6a9f": "吉斯", "d8d6f7a3c5": "麦科斯", diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 5f7f71ca94a..849c4acd9eb 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -7,6 +7,7 @@ import { useAppStore } from '@/store' import { DEFAULT_AGENT_HIBERNATION_IDLE_MS } from './agent-hibernation-planner' import { resetAgentHibernationCoordinatorForTests, + RUNTIME_LIVENESS_READ_CONCURRENCY, runAgentHibernationTick, startAgentHibernationCoordinator } from './agent-hibernation-coordinator' @@ -183,6 +184,61 @@ afterEach(() => { }) describe('agent sleep coordinator', () => { + it.each([ + ['at the limit', RUNTIME_LIVENESS_READ_CONCURRENCY], + ['above the limit', RUNTIME_LIVENESS_READ_CONCURRENCY + 1] + ])('bounds runtime liveness reads %s', async (_, count) => { + const worktrees = Array.from({ length: count }, (_, index) => ({ + id: `repo::/worktree-${index}`, + repoId: 'repo', + hostId: `runtime:env-${index}` + })) + useAppStore.setState({ + tabsByWorktree: Object.fromEntries(worktrees.map((worktree) => [worktree.id, []])), + worktreesByRepo: { repo: worktrees } as never, + repos: [], + settings: { experimentalAgentHibernation: true } as never + }) + let active = 0 + let peak = 0 + let started = 0 + const releases: (() => void)[] = [] + mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => { + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return Promise.resolve(compatible) + } + if (args.method === 'terminal.list') { + started++ + active++ + peak = Math.max(peak, active) + return new Promise((resolve) => { + releases.push(() => { + active-- + resolve({ + id: 'terminal-list', + ok: true, + result: runtimeListResult([]), + _meta: { runtimeId: 'runtime-1' } + }) + }) + }) + } + return Promise.resolve({ id: 'default', ok: true, result: {} }) + }) + + const tick = runAgentHibernationTick() + await vi.waitFor(() => expect(started).toBe(Math.min(count, RUNTIME_LIVENESS_READ_CONCURRENCY))) + if (count > RUNTIME_LIVENESS_READ_CONCURRENCY) { + releases.shift()?.() + await vi.waitFor(() => expect(started).toBe(count)) + } + releases.splice(0).forEach((release) => release()) + await tick + + expect(peak).toBe(Math.min(count, RUNTIME_LIVENESS_READ_CONCURRENCY)) + }) + it('hibernates an eligible background worktree after two stable ticks', async () => { vi.useFakeTimers() const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined)) diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index d410a12781b..1f4ae6382b7 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -22,8 +22,10 @@ import type { RuntimeTerminalListResult, RuntimeTerminalSummary } from '../../../shared/runtime-types' +import { mapWithConcurrency } from '../../../shared/map-with-concurrency' export const AGENT_HIBERNATION_TICK_MS = 60 * 1000 +export const RUNTIME_LIVENESS_READ_CONCURRENCY = 2 type IntervalHandle = ReturnType @@ -103,8 +105,10 @@ async function collectRuntimePtyLiveness(state: AppState): Promise = {} const runtimeLivenessRequiredWorktreeIds = [...targets.keys()] - await Promise.all( - [...targets].map(async ([worktreeId, runtimeEnvironmentId]) => { + await mapWithConcurrency( + [...targets], + RUNTIME_LIVENESS_READ_CONCURRENCY, + async ([worktreeId, runtimeEnvironmentId]) => { try { const result = await callRuntimeRpc( { kind: 'environment', environmentId: runtimeEnvironmentId }, @@ -134,7 +138,7 @@ async function collectRuntimePtyLiveness(state: AppState): Promise { - it('keeps the custom session store after the resumed process reports its id', () => { - const store = createTestStore() - store.setState({ - activeRepoId: 'repo-1', - activeWorktreeId: 'wt-1', - folderWorkspaces: [], - projectGroups: [], - projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], - repos: [{ id: 'repo-1', path: '/repo' }], - settings: { - agentDefaultArgs: { omp: '--model custom' }, - agentDefaultEnv: { omp: { OMP_PROFILE: 'custom' } } - } as unknown as NonNullable, - tabsByWorktree: { - 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] - }, - worktreesByRepo: { - 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] - } - } as unknown as Partial) - - const startup = buildAiVaultResumeStartupForWorktree({ - state: store.getState(), - worktreeId: 'wt-1', - session: { - agent: 'omp', - sessionId: 'omp-session-1', - filePath: '/custom/omp-sessions/project/session.jsonl', - cwd: '/repo', - codexHome: null - } - }) - - expect(startup).toMatchObject({ - command: - "cd '/repo' && omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'", - env: { OMP_PROFILE: 'custom' }, - launchConfig: { - agentCommand: "omp '--model' 'custom'", - agentArgs: '--model custom', - agentEnv: { OMP_PROFILE: 'custom' }, - ompResumeFilePath: '/custom/omp-sessions/project/session.jsonl' - }, - providerSession: { key: 'session_id', id: 'omp-session-1' } - }) - - store.getState().registerAgentLaunchConfig('tab-1:leaf-1', startup.launchConfig!, { - agentType: 'omp', - launchToken: 'launch-1', - tabId: 'tab-1', - leafId: 'leaf-1' - }) - store - .getState() - .recordAgentProviderSession( - 'tab-1:leaf-1', - 'omp', - startup.providerSession!, - { updatedAt: 10 }, - { tabId: 'tab-1', worktreeId: 'wt-1' }, - { launchToken: 'launch-1' } - ) - store - .getState() - .setAgentStatus( - 'tab-1:leaf-1', - { state: 'working', prompt: 'resume', agentType: 'omp' }, - 'OMP', - { updatedAt: 20, stateStartedAt: 20 }, - { tabId: 'tab-1', worktreeId: 'wt-1' }, - { providerSession: startup.providerSession, launchToken: 'launch-1' } - ) - store.getState().captureAllSleepingAgentSessions('quit') - - const record = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']! - const coldStartup = buildAgentResumeStartupPlan({ - agent: record.agent, - providerSession: record.providerSession, - cmdOverrides: {}, - agentArgs: record.launchConfig?.agentArgs, - agentEnv: record.launchConfig?.agentEnv, - agentCommand: record.launchConfig?.agentCommand, - ompResumeFilePath: record.launchConfig?.ompResumeFilePath, - platform: 'linux' - }) - - expect(record).toMatchObject({ - providerSession: { key: 'session_id', id: 'omp-session-1' }, - launchConfig: { - agentEnv: { OMP_PROFILE: 'custom' }, - ompResumeFilePath: '/custom/omp-sessions/project/session.jsonl' - }, - origin: 'quit' - }) - expect(coldStartup).toMatchObject({ - launchCommand: - "omp '--model' 'custom' '--resume' '/custom/omp-sessions/project/session.jsonl'", - env: { OMP_PROFILE: 'custom' } - }) - }) -}) diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index d46d028081f..2fe733d1fe2 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -68,8 +68,7 @@ function isLaunchConfig(value: unknown): value is SleepingAgentLaunchConfig { return ( (config.agentCommand === undefined || typeof config.agentCommand === 'string') && typeof config.agentArgs === 'string' && - isStringRecord(config.agentEnv) && - (config.ompResumeFilePath === undefined || isNonEmptyString(config.ompResumeFilePath)) + isStringRecord(config.agentEnv) ) } diff --git a/src/renderer/src/lib/github-work-item-details-cache.test.tsx b/src/renderer/src/lib/github-work-item-details-cache.test.tsx new file mode 100644 index 00000000000..2cbe64d594e --- /dev/null +++ b/src/renderer/src/lib/github-work-item-details-cache.test.tsx @@ -0,0 +1,204 @@ +// @vitest-environment happy-dom +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import type { GitHubWorkItemDetails } from '../../../shared/types' +import { + WORK_ITEM_DETAILS_CACHE_MAX, + WORK_ITEM_DETAILS_CACHE_MAX_KEY_BYTES, + WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES, + clearWorkItemDetailsCacheForTests, + createWorkItemDetailsCacheController, + getWorkItemDetailsCacheEntry, + getWorkItemDetailsCacheGeneration, + getWorkItemDetailsCacheKey, + invalidateWorkItemDetailsCacheByMatch, + invalidateWorkItemDetailsCacheForKey, + touchWorkItemDetailsCache, + useWorkItemDetailsCacheEntry, + type WorkItemDetailsCacheEntry +} from './github-work-item-details-cache' +import { measureWorkItemDetailsCacheEntryBytes } from './github-work-item-details-retained-bytes' + +function details(body = ''): GitHubWorkItemDetails { + return { + item: { + id: 'issue-1', + type: 'issue', + number: 1, + title: 'Issue', + state: 'open', + url: 'https://github.com/acme/repo/issues/1', + labels: [], + updatedAt: '2026-01-01T00:00:00.000Z', + author: 'octocat' + }, + body, + comments: [] + } +} + +function entry(body = ''): WorkItemDetailsCacheEntry { + return { details: details(body), fetchedAt: 1 } +} + +function entryBytes(value: WorkItemDetailsCacheEntry, maxBytes = 1_000_000): number { + const measured = measureWorkItemDetailsCacheEntryBytes(value, maxBytes) + if (measured === null) { + throw new Error('Fixture exceeded its measurement limit') + } + return measured +} + +afterEach(() => { + clearWorkItemDetailsCacheForTests() + document.body.replaceChildren() +}) + +describe('work-item details cache controller', () => { + it('retains a settled value exactly at the production limit', () => { + const controller = createWorkItemDetailsCacheController() + const baseBytes = entryBytes(entry(), WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES) + const exact = entry('x'.repeat(WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES - baseBytes)) + + expect( + measureWorkItemDetailsCacheEntryBytes(exact, WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES) + ).toBe(WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES) + expect(controller.set('exact', exact)).toBe(true) + expect(controller.get('exact')).toBe(exact) + }) + + it('rejects one byte over the value limit and removes the stale entry', () => { + const maxValueBytes = 512 + const controller = createWorkItemDetailsCacheController({ + maxValueBytes, + maxAggregateBytes: 2_000 + }) + const baseBytes = entryBytes(entry(), maxValueBytes) + const exact = entry('x'.repeat(maxValueBytes - baseBytes)) + const over = entry('x'.repeat(maxValueBytes - baseBytes + 1)) + + expect(controller.set('key', exact)).toBe(true) + expect(controller.set('key', over)).toBe(false) + expect(controller.get('key')).toBeUndefined() + expect(controller.getRetainedBytes()).toBe(0) + }) + + it('measures the 4 KiB key limit in UTF-8 bytes', () => { + const controller = createWorkItemDetailsCacheController({ + maxKeyBytes: WORK_ITEM_DETAILS_CACHE_MAX_KEY_BYTES + }) + const exactKey = '😀'.repeat(WORK_ITEM_DETAILS_CACHE_MAX_KEY_BYTES / 4) + const oversizedKey = `${exactKey}a` + + expect(controller.set(exactKey, entry())).toBe(true) + expect(controller.set(oversizedKey, entry())).toBe(false) + expect(controller.get(exactKey)).toBeDefined() + expect(controller.get(oversizedKey)).toBeUndefined() + }) + + it('globally evicts the least-recently-used settled entry under aggregate pressure', () => { + const value = entry('payload') + const retainedValueBytes = entryBytes(value) + const controller = createWorkItemDetailsCacheController({ + maxAggregateBytes: 2 * (retainedValueBytes + 1), + maxValueBytes: retainedValueBytes + }) + + controller.set('a', value) + controller.set('b', value) + expect(controller.getRetainedBytes()).toBe(2 * (retainedValueBytes + 1)) + expect(controller.get('a')).toBe(value) + controller.set('c', value) + + expect(controller.get('a')).toBe(value) + expect(controller.get('b')).toBeUndefined() + expect(controller.get('c')).toBe(value) + }) + + it('keeps pending promises count-bounded without measuring the promise object', () => { + const controller = createWorkItemDetailsCacheController() + const pending = Promise.resolve(null) + + for (let index = 0; index <= WORK_ITEM_DETAILS_CACHE_MAX; index += 1) { + expect( + controller.set(`pending-${index}`, { + details: null, + fetchedAt: 0, + pending + }) + ).toBe(true) + } + + expect(controller.getSize()).toBe(WORK_ITEM_DETAILS_CACHE_MAX) + expect(controller.get('pending-0')).toBeUndefined() + expect(controller.get(`pending-${WORK_ITEM_DETAILS_CACHE_MAX}`)?.pending).toBe(pending) + }) + + it('releases aggregate accounting when matching entries are invalidated', () => { + const controller = createWorkItemDetailsCacheController() + controller.set('repo-a\u0000auto\u0000issue\u00001', entry('first')) + controller.set('repo-b\u0000auto\u0000issue\u00001', entry('second')) + const retainedBefore = controller.getRetainedBytes() + + expect(controller.deleteMatching((key) => key.startsWith('repo-a\u0000'))).toEqual([ + 'repo-a\u0000auto\u0000issue\u00001' + ]) + expect(controller.getRetainedBytes()).toBeLessThan(retainedBefore) + controller.clear() + expect(controller.getRetainedBytes()).toBe(0) + }) +}) + +describe('shared work-item details cache', () => { + it('preserves exact-key and match invalidation generation semantics', () => { + const key = getWorkItemDetailsCacheKey({ + repoPath: '/repo', + repoId: 'repo-1', + issueSourcePreference: undefined, + type: 'issue', + number: 1 + }) + touchWorkItemDetailsCache(key, entry()) + const initialGeneration = getWorkItemDetailsCacheGeneration() + + invalidateWorkItemDetailsCacheByMatch({ + repoPath: '/repo', + repoId: 'repo-1', + type: 'issue', + number: 1 + }) + expect(getWorkItemDetailsCacheGeneration()).toBe(initialGeneration + 1) + expect(getWorkItemDetailsCacheEntry(key)).toBeUndefined() + + invalidateWorkItemDetailsCacheForKey(key) + expect(getWorkItemDetailsCacheGeneration()).toBe(initialGeneration + 2) + }) + + it('renders an oversized result only while its consumer remains mounted', () => { + const key = 'mounted' + const oversized = entry('x'.repeat(WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES)) + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + + function Probe(): React.JSX.Element { + const cached = useWorkItemDetailsCacheEntry(key) + return createElement('span', null, cached?.details?.body.length ?? 'missing') + } + + act(() => root.render(createElement(Probe))) + act(() => { + expect(touchWorkItemDetailsCache(key, oversized)).toBe(false) + }) + + expect(container.textContent).toBe(String(oversized.details?.body.length)) + expect(getWorkItemDetailsCacheEntry(key)).toBeUndefined() + + act(() => root.unmount()) + const remountedRoot: Root = createRoot(container) + act(() => remountedRoot.render(createElement(Probe))) + expect(container.textContent).toBe('missing') + act(() => remountedRoot.unmount()) + }) +}) diff --git a/src/renderer/src/lib/github-work-item-details-cache.ts b/src/renderer/src/lib/github-work-item-details-cache.ts new file mode 100644 index 00000000000..98b27e57fe3 --- /dev/null +++ b/src/renderer/src/lib/github-work-item-details-cache.ts @@ -0,0 +1,263 @@ +import type { GitHubWorkItemDetails } from '../../../shared/types' +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { + onGitHubWorkItemDetailsCacheMutation, + type GitHubWorkItemDetailsCacheMutation +} from './github-work-item-details-cache-events' +import { measureWorkItemDetailsCacheEntryBytes } from './github-work-item-details-retained-bytes' + +export const WORK_ITEM_DETAILS_CACHE_MAX = 50 +export const WORK_ITEM_DETAILS_CACHE_MAX_AGGREGATE_BYTES = 32 * 1024 * 1024 +export const WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES = 16 * 1024 * 1024 +export const WORK_ITEM_DETAILS_CACHE_MAX_KEY_BYTES = 4 * 1024 +export const WORK_ITEM_DETAILS_FRESH_MS = 30_000 +export const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.' + +export type WorkItemDetailsCacheEntry = { + details: GitHubWorkItemDetails | null + fetchedAt: number + pending?: Promise + error?: string +} + +type WorkItemDetailsCacheLimits = { + maxEntries: number + maxAggregateBytes: number + maxValueBytes: number + maxKeyBytes: number +} + +type WorkItemDetailsCacheActivity = + | { type: 'write'; key: string; entry: WorkItemDetailsCacheEntry; retained: boolean } + | { type: 'invalidate-key'; key: string } + | { type: 'invalidate-match'; match: GitHubWorkItemDetailsCacheMutation } + +const DEFAULT_LIMITS: WorkItemDetailsCacheLimits = { + maxEntries: WORK_ITEM_DETAILS_CACHE_MAX, + maxAggregateBytes: WORK_ITEM_DETAILS_CACHE_MAX_AGGREGATE_BYTES, + maxValueBytes: WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES, + maxKeyBytes: WORK_ITEM_DETAILS_CACHE_MAX_KEY_BYTES +} + +export function createWorkItemDetailsCacheController( + overrides: Partial = {} +) { + const limits = { ...DEFAULT_LIMITS, ...overrides } + const entries = new Map() + let retainedBytes = 0 + const remove = (key: string): boolean => { + const existing = entries.get(key) + if (!existing) { + return false + } + retainedBytes -= existing.bytes + return entries.delete(key) + } + const evictOldest = (): void => { + const oldest = entries.keys().next().value + if (oldest !== undefined) { + remove(oldest) + } + } + return { + get: (key) => { + const retained = entries.get(key) + if (!retained) { + return undefined + } + entries.delete(key) + entries.set(key, retained) + return retained.entry + }, + set: (key, entry) => { + remove(key) + const keyBytes = measureUtf8ByteLength(key, { stopAfterBytes: limits.maxKeyBytes }) + if (keyBytes.exceededLimit) { + return false + } + const valueBytes = measureWorkItemDetailsCacheEntryBytes(entry, limits.maxValueBytes) + if (valueBytes === null || keyBytes.byteLength + valueBytes > limits.maxAggregateBytes) { + return false + } + const bytes = keyBytes.byteLength + valueBytes + entries.set(key, { entry, bytes }) + retainedBytes += bytes + while (entries.size > limits.maxEntries || retainedBytes > limits.maxAggregateBytes) { + evictOldest() + } + return entries.get(key)?.entry === entry + }, + delete: remove, + deleteMatching: (predicate) => { + const removed: string[] = [] + for (const key of entries.keys()) { + if (predicate(key) && remove(key)) { + removed.push(key) + } + } + return removed + }, + clear: () => { + entries.clear() + retainedBytes = 0 + }, + getRetainedBytes: () => retainedBytes, + getSize: () => entries.size + } +} + +const sharedCache = createWorkItemDetailsCacheController() +const cacheListeners = new Set<() => void>() +const activityListeners = new Set<(activity: WorkItemDetailsCacheActivity) => boolean | void>() +let cacheGeneration = 0 + +function notifyCache(): void { + for (const listener of cacheListeners) { + listener() + } +} + +function emitActivity(activity: WorkItemDetailsCacheActivity): boolean { + let matched = false + for (const listener of activityListeners) { + matched = listener(activity) === true || matched + } + return matched +} + +export function getWorkItemDetailsCacheKey(args: { + repoPath: string + repoId: string + issueSourcePreference: string | undefined + sourceCacheScope?: string | null + type: 'issue' | 'pr' + number: number +}): string { + const keyParts = args.sourceCacheScope + ? [args.repoId, args.sourceCacheScope, args.issueSourcePreference ?? 'auto', args.type] + : [args.repoId, args.issueSourcePreference ?? 'auto', args.type] + return [...keyParts, args.number].join('\0') +} + +export function getWorkItemDetailsCacheEntry(key: string): WorkItemDetailsCacheEntry | undefined { + return sharedCache.get(key) +} + +export function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): boolean { + const retained = sharedCache.set(key, entry) + emitActivity({ type: 'write', key, entry, retained }) + notifyCache() + return retained +} + +export function subscribeWorkItemDetailsCache(listener: () => void): () => void { + cacheListeners.add(listener) + return () => cacheListeners.delete(listener) +} + +export function subscribeWorkItemDetailsCacheActivity( + listener: (activity: WorkItemDetailsCacheActivity) => boolean | void +): () => void { + activityListeners.add(listener) + return () => activityListeners.delete(listener) +} + +export function getWorkItemDetailsCacheGeneration(): number { + return cacheGeneration +} + +export function matchesWorkItemDetailsCacheInvalidation( + key: string, + args: GitHubWorkItemDetailsCacheMutation +): boolean { + const prefix = `${args.repoId ?? args.repoPath}\0` + return key.startsWith(prefix) && key.endsWith(`\0${args.type}\0${args.number}`) +} + +export function useWorkItemDetailsCacheEntry( + key: string | null +): WorkItemDetailsCacheEntry | undefined { + const retainedEntry = useSyncExternalStore( + subscribeWorkItemDetailsCache, + useCallback(() => (key ? getWorkItemDetailsCacheEntry(key) : undefined), [key]) + ) + const unretainedRef = useRef<{ key: string; entry: WorkItemDetailsCacheEntry } | null>(null) + const lastKeyRef = useRef(key) + const [, setRevision] = useState(0) + if (lastKeyRef.current !== key) { + lastKeyRef.current = key + unretainedRef.current = null + } + useEffect( + () => + subscribeWorkItemDetailsCacheActivity((activity) => { + if (!key) { + return false + } + if (activity.type === 'write' && activity.key === key) { + unretainedRef.current = activity.retained ? null : { key, entry: activity.entry } + setRevision((current) => current + 1) + return false + } + const invalidated = + (activity.type === 'invalidate-key' && activity.key === key) || + (activity.type === 'invalidate-match' && + matchesWorkItemDetailsCacheInvalidation(key, activity.match)) + if (!invalidated) { + return false + } + const hadUnretainedEntry = unretainedRef.current?.key === key + unretainedRef.current = null + setRevision((current) => current + 1) + return hadUnretainedEntry + }), + [key] + ) + return ( + retainedEntry ?? (unretainedRef.current?.key === key ? unretainedRef.current.entry : undefined) + ) +} + +export function invalidateWorkItemDetailsCacheForKey(key: string): void { + cacheGeneration += 1 + const existed = sharedCache.delete(key) + emitActivity({ type: 'invalidate-key', key }) + if (existed) { + notifyCache() + } +} + +export function invalidateWorkItemDetailsCacheByMatch( + args: GitHubWorkItemDetailsCacheMutation +): void { + const removed = sharedCache.deleteMatching((key) => + matchesWorkItemDetailsCacheInvalidation(key, args) + ) + const transientMatched = emitActivity({ type: 'invalidate-match', match: args }) + if (removed.length > 0 || transientMatched) { + cacheGeneration += 1 + } + if (removed.length > 0) { + notifyCache() + } +} + +export function clearWorkItemDetailsCacheForTests(): void { + sharedCache.clear() + cacheGeneration = 0 + notifyCache() +} + +let workItemMutatedUnsub: (() => void) | undefined +let cacheEventUnsub: (() => void) | undefined +if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { + workItemMutatedUnsub = window.api.gh.onWorkItemMutated(invalidateWorkItemDetailsCacheByMatch) + cacheEventUnsub = onGitHubWorkItemDetailsCacheMutation(invalidateWorkItemDetailsCacheByMatch) +} +if (typeof import.meta !== 'undefined' && import.meta.hot) { + import.meta.hot.dispose(() => { + workItemMutatedUnsub?.() + cacheEventUnsub?.() + }) +} diff --git a/src/renderer/src/lib/github-work-item-details-retained-bytes.ts b/src/renderer/src/lib/github-work-item-details-retained-bytes.ts new file mode 100644 index 00000000000..d7a516e7104 --- /dev/null +++ b/src/renderer/src/lib/github-work-item-details-retained-bytes.ts @@ -0,0 +1,139 @@ +import type { GitHubWorkItemDetails } from '../../../shared/types' +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' + +const CONTAINER_BYTES = 8 +const CONTAINER_ENTRY_BYTES = 8 +const CACHE_ENTRY_BYTES = 16 + +type MeasuredWorkItemDetailsCacheEntry = { + details: GitHubWorkItemDetails | null + error?: string +} + +type MeasurementFrame = + | { kind: 'value'; value: unknown } + | { kind: 'array'; value: readonly unknown[]; index: number } + | { kind: 'record'; value: Record; keys: Generator } + +function* ownEnumerableKeys(value: Record): Generator { + for (const key in value) { + if (Object.hasOwn(value, key)) { + yield key + } + } +} + +function addBytes(current: number, additional: number, maxBytes: number): number | null { + const next = current + additional + return next <= maxBytes ? next : null +} + +function addStringBytes(current: number, value: string, maxBytes: number): number | null { + const measured = measureUtf8ByteLength(value, { + stopAfterBytes: Math.max(0, maxBytes - current) + }) + if (measured.exceededLimit) { + return null + } + return addBytes(current, measured.byteLength, maxBytes) +} + +function measureRetainedValue(value: unknown, maxBytes: number, initialBytes = 0): number | null { + const seen = new WeakSet() + const frames: MeasurementFrame[] = [{ kind: 'value', value }] + let bytes = initialBytes + while (frames.length > 0) { + const frame = frames.pop() + if (!frame) { + break + } + if (frame.kind === 'array') { + if (frame.index >= frame.value.length) { + continue + } + const index = frame.index + frame.index += 1 + frames.push(frame) + if (Object.hasOwn(frame.value, index)) { + frames.push({ kind: 'value', value: frame.value[index] }) + } + continue + } + if (frame.kind === 'record') { + const nextKey = frame.keys.next() + if (nextKey.done) { + continue + } + const withEntry = addBytes(bytes, CONTAINER_ENTRY_BYTES, maxBytes) + const withKey = withEntry === null ? null : addStringBytes(withEntry, nextKey.value, maxBytes) + if (withKey === null) { + return null + } + bytes = withKey + frames.push(frame) + frames.push({ kind: 'value', value: frame.value[nextKey.value] }) + continue + } + + const current = frame.value + if (current === null || current === undefined) { + continue + } + if (typeof current === 'string') { + const measured = addStringBytes(bytes, current, maxBytes) + if (measured === null) { + return null + } + bytes = measured + continue + } + if (typeof current === 'number' || typeof current === 'boolean') { + const measured = addBytes(bytes, typeof current === 'number' ? 8 : 1, maxBytes) + if (measured === null) { + return null + } + bytes = measured + continue + } + if (typeof current !== 'object' || seen.has(current)) { + if (typeof current !== 'object') { + return null + } + continue + } + + seen.add(current) + const withContainer = addBytes(bytes, CONTAINER_BYTES, maxBytes) + if (withContainer === null) { + return null + } + bytes = withContainer + if (Array.isArray(current)) { + const withEntries = addBytes(bytes, current.length * CONTAINER_ENTRY_BYTES, maxBytes) + if (withEntries === null) { + return null + } + bytes = withEntries + frames.push({ kind: 'array', value: current, index: 0 }) + continue + } + const prototype = Object.getPrototypeOf(current) + if (prototype !== Object.prototype && prototype !== null) { + return null + } + const record = current as Record + frames.push({ kind: 'record', value: record, keys: ownEnumerableKeys(record) }) + } + return bytes +} + +export function measureWorkItemDetailsCacheEntryBytes( + entry: MeasuredWorkItemDetailsCacheEntry, + maxBytes: number +): number | null { + const detailsBytes = measureRetainedValue(entry.details, maxBytes, CACHE_ENTRY_BYTES) + if (detailsBytes === null || !entry.error) { + return detailsBytes + } + return addStringBytes(detailsBytes, entry.error, maxBytes) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index 8fa96d62c69..3a77b2c7332 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -75,7 +75,11 @@ function writeIntentSnapshot( nextTerminalScrollIntentRevision += 1 terminalScrollIntentByTerminal.set(terminal, intent) const key = terminalScrollIntentKeyByTerminal.get(terminal) - if (key) { + if ( + key && + terminalScrollIntentKeyBindingByTerminal.get(terminal) === + terminalScrollIntentBindingByKey.get(key) + ) { terminalScrollIntentByKey.set(key, intent) } return intent @@ -122,6 +126,13 @@ export function isTerminalScrollIntentKeyBindingCurrent( ) } +export function releaseTerminalScrollIntentKeys(keys: Iterable): void { + for (const key of keys) { + terminalScrollIntentByKey.delete(key) + terminalScrollIntentBindingByKey.delete(key) + } +} + export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void { writeIntent(terminal, 'followOutput') } diff --git a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts index 960c0c541a6..f88cbe02fde 100644 --- a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts @@ -9,6 +9,7 @@ import { isTerminalWritePipelineCertifiedDead, notifyUndeliverableWrite, registerUndeliverableWriteHandler, + requestTerminalWritePipelineProbe, settleTerminalWriteStallWatch, WRITE_PIPELINE_STALL_CHECK_MS } from './terminal-write-pipeline-health' @@ -113,6 +114,23 @@ describe('terminal write pipeline health', () => { _resetWritePipelineHealthForTests(terminal) }) + it('keeps a requested probe armed through unrelated parse progress', () => { + vi.useFakeTimers() + const terminal = makeTerminal() + terminal.dropCallbacks = true + const handler = vi.fn() + registerUndeliverableWriteHandler(terminal, handler) + + armTerminalWriteStallWatch(terminal) + requestTerminalWritePipelineProbe(terminal) + settleTerminalWriteStallWatch(terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) + + expect(handler).toHaveBeenCalledWith('write-stalled') + expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(true) + _resetWritePipelineHealthForTests(terminal) + }) + it('a slow-but-alive pipeline disarms when the probe parses', () => { vi.useFakeTimers() const terminal = makeTerminal() @@ -218,6 +236,32 @@ describe('terminal write pipeline health', () => { _resetWritePipelineHealthForTests(terminal) }) + it('keeps a requested probe alive while the pipeline makes parse progress', () => { + vi.useFakeTimers() + const terminal = makeTerminal() + terminal.dropCallbacks = true + const handler = vi.fn() + registerUndeliverableWriteHandler(terminal, handler) + + requestTerminalWritePipelineProbe(terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2) + settleTerminalWriteStallWatch(terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2) + settleTerminalWriteStallWatch(terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2) + + expect(handler).not.toHaveBeenCalled() + expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(false) + + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS) + + expect(handler).toHaveBeenCalledWith('write-stalled') + expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(true) + _resetWritePipelineHealthForTests(terminal) + }) + it('notifies at most once per terminal instance', () => { const terminal = makeTerminal() const handler = vi.fn() diff --git a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts index 39e0b72c239..5c67e7138ea 100644 --- a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts +++ b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts @@ -6,10 +6,11 @@ // delivery ack credits leak, and the pane becomes a fossil the user can only // cure by reloading the window. Detection here is probe-certified (mirroring // replay-guard.ts): a stalled completion triggers an empty probe write; xterm -// parses in FIFO order, so a probe that also never completes proves the -// pipeline is dead rather than slow. Certification notifies a per-terminal -// handler (registered by the pane's PTY connection) that requests pane -// recovery — a remount that rebuilds the xterm and reattaches the live PTY. +// parses in FIFO order, so a completing probe proves preceding work drained. A +// silent probe is only certified dead after a full interval without any parse +// progress. Certification notifies a per-terminal handler (registered by the +// pane's PTY connection) that requests pane recovery — a remount that rebuilds +// the xterm and reattaches the live PTY. type WriteTarget = { write(data: string, callback?: () => void): void @@ -42,9 +43,12 @@ export function hasTerminalParseProgressSince(terminal: object, generation: numb return captureTerminalParseProgressGeneration(terminal) !== generation } +type StallWatchMode = 'output-completion' | 'fifo-probe' + type StallWatch = { timer: ReturnType onCertifiedDead?: () => void + mode: StallWatchMode } const stallWatchByTerminal = new WeakMap() @@ -106,31 +110,54 @@ export function isTerminalWritePipelineCertifiedDead(terminal: object): boolean * Arm (or keep armed) the stall watch for a terminal that just had a write * issued. Cleared by settleTerminalWriteStallWatch from the write-completion * callback. If the completion never arrives, an empty probe write certifies - * dead-vs-slow exactly like replay-guard.ts: probe completes → pipeline is - * alive (slow parse), re-arm and keep waiting; probe silent for another - * interval → dead, notify. + * dead-vs-slow conservatively like replay-guard.ts: probe completes → pipeline + * is alive (slow parse), re-arm and keep waiting; probe silent for another + * interval without any parse progress → dead, notify. */ -export function armTerminalWriteStallWatch( +function armTerminalWritePipelineWatch( terminal: WriteTarget, - options: { onCertifiedDead?: () => void; stallCheckMs?: number } = {} + options: { + mode: StallWatchMode + onCertifiedDead?: () => void + stallCheckMs?: number + } ): void { - if (stallWatchByTerminal.has(terminal) || certifiedDeadTerminals.has(terminal)) { + if (certifiedDeadTerminals.has(terminal)) { + return + } + const existingWatch = stallWatchByTerminal.get(terminal) + if (existingWatch) { + if (options.mode === 'fifo-probe') { + existingWatch.mode = 'fifo-probe' + } return } const stallCheckMs = options.stallCheckMs ?? WRITE_PIPELINE_STALL_CHECK_MS const watch: StallWatch = { + mode: options.mode, onCertifiedDead: options.onCertifiedDead, timer: setTimeout(probeForStall, stallCheckMs) } const certifyDead = (): void => certifyTerminalWritePipelineDead(terminal, watch) + function armProbeQuietWindow(quietSinceGeneration: number): void { + watch.timer = setTimeout(() => { + if (stallWatchByTerminal.get(terminal) !== watch) { + return + } + if (hasTerminalParseProgressSince(terminal, quietSinceGeneration)) { + armProbeQuietWindow(captureTerminalParseProgressGeneration(terminal)) + return + } + certifyDead() + }, stallCheckMs) + } function probeForStall(): void { if (stallWatchByTerminal.get(terminal) !== watch) { return } - let probeParsed = false + const probeQueuedAtGeneration = captureTerminalParseProgressGeneration(terminal) try { terminal.write('', () => { - probeParsed = true // Why: replay guards share this terminal-scoped generation; even an // auxiliary FIFO probe proves the parser is alive and making progress. recordTerminalParseProgress(terminal) @@ -146,15 +173,28 @@ export function armTerminalWriteStallWatch( certifyDead() return } - watch.timer = setTimeout(() => { - if (!probeParsed) { - certifyDead() - } - }, stallCheckMs) + if (stallWatchByTerminal.get(terminal) === watch) { + armProbeQuietWindow(probeQueuedAtGeneration) + } } stallWatchByTerminal.set(terminal, watch) } +export function armTerminalWriteStallWatch( + terminal: WriteTarget, + options: { onCertifiedDead?: () => void; stallCheckMs?: number } = {} +): void { + armTerminalWritePipelineWatch(terminal, { ...options, mode: 'output-completion' }) +} + +/** Require the active watch to reach its FIFO probe before it may settle. */ +export function requestTerminalWritePipelineProbe( + terminal: WriteTarget, + options: { stallCheckMs?: number } = {} +): void { + armTerminalWritePipelineWatch(terminal, { ...options, mode: 'fifo-probe' }) +} + /** Cancel a pending watch without claiming that any bytes parsed. */ export function cancelTerminalWriteStallWatch(terminal: object): void { const watch = stallWatchByTerminal.get(terminal) @@ -168,6 +208,9 @@ export function cancelTerminalWriteStallWatch(terminal: object): void { /** Write completed normally — the pipeline is healthy; drop any pending watch. */ export function settleTerminalWriteStallWatch(terminal: object): void { recordTerminalParseProgress(terminal) + if (stallWatchByTerminal.get(terminal)?.mode === 'fifo-probe') { + return + } cancelTerminalWriteStallWatch(terminal) } diff --git a/src/renderer/src/lib/primary-selection.test.ts b/src/renderer/src/lib/primary-selection.test.ts index 9f11f53991a..fb98e8e7f0f 100644 --- a/src/renderer/src/lib/primary-selection.test.ts +++ b/src/renderer/src/lib/primary-selection.test.ts @@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PRIMARY_SELECTION_MAX_LENGTH, armPrimarySelectionNativePasteSuppression, + consumePrimarySelectionNativePasteSuppression, getPrimarySelectionText, readPrimarySelectionText, resetPrimarySelectionForTests, setPrimarySelectionEnabled, setPrimarySelectionText, - shouldSuppressPrimarySelectionNativePaste, shouldUseSystemPrimarySelectionClipboard } from './primary-selection' @@ -144,29 +144,55 @@ describe('primary-selection native paste suppression', () => { it('does not arm suppression while primary selection is disabled', () => { armPrimarySelectionNativePasteSuppression(1_000) - expect(shouldSuppressPrimarySelectionNativePaste(1_000)).toBe(false) + expect(consumePrimarySelectionNativePasteSuppression(1_000)).toBe(false) }) it('does not arm suppression off Linux, where there is no native follow-up paste', () => { vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }) setPrimarySelectionEnabled(true) armPrimarySelectionNativePasteSuppression(1_000) - expect(shouldSuppressPrimarySelectionNativePaste(1_000)).toBe(false) + expect(consumePrimarySelectionNativePasteSuppression(1_000)).toBe(false) }) it('suppresses the follow-up native paste within the armed window', () => { setPrimarySelectionEnabled(true) armPrimarySelectionNativePasteSuppression(1_000) - expect(shouldSuppressPrimarySelectionNativePaste(1_000)).toBe(true) - expect(shouldSuppressPrimarySelectionNativePaste(1_700)).toBe(true) + expect(consumePrimarySelectionNativePasteSuppression(1_000)).toBe(true) + }) + + it('suppresses a follow-up that arrives late in the armed window', () => { + setPrimarySelectionEnabled(true) + armPrimarySelectionNativePasteSuppression(1_000) + + expect(consumePrimarySelectionNativePasteSuppression(1_700)).toBe(true) + }) + + it('lets a real paste through after the follow-up is consumed inside the window', () => { + setPrimarySelectionEnabled(true) + armPrimarySelectionNativePasteSuppression(1_000) + + expect(consumePrimarySelectionNativePasteSuppression(1_000)).toBe(true) + expect(consumePrimarySelectionNativePasteSuppression(1_050)).toBe(false) + expect(consumePrimarySelectionNativePasteSuppression(1_700)).toBe(false) + }) + + it('consumes only one follow-up per arm across repeated middle-clicks', () => { + setPrimarySelectionEnabled(true) + armPrimarySelectionNativePasteSuppression(1_000) + expect(consumePrimarySelectionNativePasteSuppression(1_010)).toBe(true) + expect(consumePrimarySelectionNativePasteSuppression(1_020)).toBe(false) + + armPrimarySelectionNativePasteSuppression(1_100) + expect(consumePrimarySelectionNativePasteSuppression(1_110)).toBe(true) + expect(consumePrimarySelectionNativePasteSuppression(1_120)).toBe(false) }) it('stops suppressing once the armed window elapses', () => { setPrimarySelectionEnabled(true) armPrimarySelectionNativePasteSuppression(1_000) - expect(shouldSuppressPrimarySelectionNativePaste(1_800)).toBe(false) + expect(consumePrimarySelectionNativePasteSuppression(1_800)).toBe(false) }) it('clears the armed window when primary selection is disabled', () => { @@ -176,6 +202,6 @@ describe('primary-selection native paste suppression', () => { setPrimarySelectionEnabled(false) setPrimarySelectionEnabled(true) - expect(shouldSuppressPrimarySelectionNativePaste(1_000)).toBe(false) + expect(consumePrimarySelectionNativePasteSuppression(1_000)).toBe(false) }) }) diff --git a/src/renderer/src/lib/primary-selection.ts b/src/renderer/src/lib/primary-selection.ts index d6bc55ac43e..cda075ccf9e 100644 --- a/src/renderer/src/lib/primary-selection.ts +++ b/src/renderer/src/lib/primary-selection.ts @@ -63,8 +63,14 @@ export function armPrimarySelectionNativePasteSuppression(now: number = Date.now nativePasteSuppressionUntil = now + PRIMARY_SELECTION_NATIVE_PASTE_SUPPRESSION_MS } -export function shouldSuppressPrimarySelectionNativePaste(now: number = Date.now()): boolean { - return enabled && now <= nativePasteSuppressionUntil +// Why: single-shot — the arm owes exactly one follow-up paste, so clearing the +// deadline on consume keeps a real keyboard paste inside the same 750ms alive. +export function consumePrimarySelectionNativePasteSuppression(now: number = Date.now()): boolean { + if (!enabled || nativePasteSuppressionUntil === 0 || now > nativePasteSuppressionUntil) { + return false + } + nativePasteSuppressionUntil = 0 + return true } export function isPrimarySelectionEnabled(): boolean { diff --git a/src/renderer/src/lib/react-error-boundary-reporting.ts b/src/renderer/src/lib/react-error-boundary-reporting.ts index 3b525f7c6b3..46c6437937e 100644 --- a/src/renderer/src/lib/react-error-boundary-reporting.ts +++ b/src/renderer/src/lib/react-error-boundary-reporting.ts @@ -100,13 +100,25 @@ function rememberRendererErrorKey(key: string): boolean { } function getRendererErrorKey(args: ReactErrorBoundaryReportArgs): string { - return JSON.stringify({ - boundaryId: args.boundaryId, - surface: args.surface, - errorName: args.errorName, - errorMessage: args.errorMessage, - componentStack: args.componentStack - }) + // Why: error text can be arbitrarily large; retain only a fixed-size dedupe identity. + let first = 2166136261 + let second = 2246822507 + for (const value of [ + args.boundaryId, + args.surface, + args.errorName, + args.errorMessage, + args.componentStack ?? '' + ]) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + first = Math.imul(first ^ code, 16777619) + second = Math.imul(second ^ code, 3266489909) + } + first = Math.imul(first ^ value.length, 16777619) + second = Math.imul(second ^ value.length, 668265263) + } + return `${first >>> 0}:${second >>> 0}` } export function takePendingReactErrorBoundaryReport(): CrashReportRecord | null { diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 34c88bec363..92169c8c8d2 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -1,18 +1,5 @@ -// Why: Project mode rows carry a GitHub `owner/repo` slug, but Orca's -// `state.repos` stores only absolute paths. Before any repo-context action -// (opening the item dialog in repo-backed mode, launching a worktree) can -// dispatch correctly, we need a renderer-side index mapping slug → Repo[]. -// -// The index is built lazily from `window.api.gh.repoSlug({ repoPath })` — -// the main-process resolver that reads `git remote` and classifies the -// remote into `owner/repo`. Repos whose slug cannot be resolved (no GitHub -// remote, SSH lookup failure) are excluded; the design doc (§Row actions) -// says to keep the unknown-repo fallback in that case. -// -// The index rebuilds only when `state.repos` changes — adding or removing -// a repo is rare enough that a full re-resolution is simpler than per-id -// invalidation, and the underlying IPC result is itself cached by the main -// process (`repoSlug` reads `.git/config`). +// Project rows carry slugs while repo state carries paths, so repo-context +// actions need a lazily resolved slug → Repo[] index. import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import type { Repo } from '../../../shared/types' @@ -30,22 +17,35 @@ import { type SlugIndex } from './repo-slug-cache' import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' +import { resolveRepoSlugsWithFixedWorkers } from './repo-slug-resolution-pool' export { lookupReposBySlugFromCache } from './repo-slug-cache' +export { REPO_SLUG_RESOLUTION_CONCURRENCY } from './repo-slug-resolution-pool' const slugResolutionInFlight = new Map>() -// Why: an invalidation (repo removed, remote changed) can land while a -// resolution is in-flight — before it ever wrote to `slugByRepoId`. Deleting -// the in-flight promise doesn't stop its pending `rememberRepoSlug` write, so a -// stale slug would repopulate the cache after invalidation. Bump the key's -// generation on every invalidation and commit a result only if the generation -// it started with is still current. -const slugResolutionGeneration = new Map() +const slugResolutionTokenByCacheKey = new Map() + +type RepoSlugResolver = ( + repo: Repo, + settings: Pick | null | undefined +) => Promise + +type RepoSlugIndexBuildResult = { index: SlugIndex; retryDelayMs: number | null } + +let sharedIndexBuild: + | { + repos: readonly Repo[] + settings: GlobalSettings | null | undefined + resolver: RepoSlugResolver + controller: AbortController + promise: Promise + } + | undefined function invalidateSlugResolution(cacheKey: string): void { slugResolutionInFlight.delete(cacheKey) - slugResolutionGeneration.set(cacheKey, (slugResolutionGeneration.get(cacheKey) ?? 0) + 1) + slugResolutionTokenByCacheKey.delete(cacheKey) } // Why: clear after remove/remote-change so the next index build re-resolves. @@ -72,13 +72,23 @@ export function clearRepoSlugCacheEntry(repoId: string): void { /** Clear the entire slug cache. Useful for tests or full repo-list resets. */ export function clearRepoSlugCache(): void { + sharedIndexBuild?.controller.abort() + sharedIndexBuild = undefined clearRepoSlugCacheValues() - for (const key of slugResolutionInFlight.keys()) { - slugResolutionGeneration.set(key, (slugResolutionGeneration.get(key) ?? 0) + 1) - } + slugResolutionTokenByCacheKey.clear() slugResolutionInFlight.clear() } +export function getRepoSlugResolutionStateSizesForTests(): { + inFlight: number + tokens: number +} { + return { + inFlight: slugResolutionInFlight.size, + tokens: slugResolutionTokenByCacheKey.size + } +} + async function resolveRepoSlug( repo: Repo, settings: Pick | null | undefined @@ -92,12 +102,13 @@ async function resolveRepoSlug( if (inFlight) { return inFlight } - const generation = slugResolutionGeneration.get(cacheKey) ?? 0 + const resolutionToken = {} + slugResolutionTokenByCacheKey.set(cacheKey, resolutionToken) const resolution = (async () => { // Why: only write the resolved value if this key wasn't invalidated // mid-flight; otherwise a stale slug would repopulate the cache. const commit = (value: string | null): string | null => { - if ((slugResolutionGeneration.get(cacheKey) ?? 0) === generation) { + if (slugResolutionTokenByCacheKey.get(cacheKey) === resolutionToken) { rememberRepoSlug(cacheKey, value) } return value @@ -131,41 +142,111 @@ async function resolveRepoSlug( if (slugResolutionInFlight.get(cacheKey) === resolution) { slugResolutionInFlight.delete(cacheKey) } + if (slugResolutionTokenByCacheKey.get(cacheKey) === resolutionToken) { + slugResolutionTokenByCacheKey.delete(cacheKey) + } } } +function indexResolvedRepoSlugs( + repos: readonly Repo[], + slugs: readonly (string | null)[] +): SlugIndex { + const index: SlugIndex = new Map() + for (let repoIndex = 0; repoIndex < repos.length; repoIndex += 1) { + const slug = slugs[repoIndex] + if (slug) { + const repo = repos[repoIndex] + const matches = index.get(slug) + if (matches) { + matches.push(repo) + } else { + index.set(slug, [repo]) + } + } + } + return index +} + +/** @internal - fixed-worker regression coverage only. */ +export async function buildRepoSlugIndexForTests( + repos: readonly Repo[], + resolver: (repo: Repo) => Promise +): Promise { + const slugs = await resolveRepoSlugsWithFixedWorkers(repos, resolver) + return indexResolvedRepoSlugs(repos, slugs) +} + async function buildIndex( repos: Repo[], - settings: Pick | null | undefined -): Promise<{ index: SlugIndex; retryDelayMs: number | null }> { + settings: GlobalSettings | null | undefined, + resolver: RepoSlugResolver = resolveRepoSlug, + signal?: AbortSignal +): Promise { // Why: evict cached entries for repos that no longer exist in state so // the cache cannot grow unbounded across long sessions where users add // and remove repos. Without this, every removed repo's id (and its // negative-cached null) lingers forever. - const liveKeys = new Set(repos.map((r) => slugCacheKey(r.id, settingsForRepoOwner(r, settings)))) + const liveKeys = new Set() + for (const repo of repos) { + liveKeys.add(slugCacheKey(repo.id, settingsForRepoOwner(repo, settings))) + } for (const key of slugByRepoId.keys()) { if (!liveKeys.has(key)) { deleteRepoSlugCacheKey(key) invalidateSlugResolution(key) } } - const next: SlugIndex = new Map() - const results = await Promise.all( - repos.map(async (r) => ({ - repo: r, - // Why: the project slug index spans repos from multiple hosts; each - // repo's remote metadata must be read from its owner. - slug: await resolveRepoSlug(r, settingsForRepoOwner(r, settings)) - })) - ) - for (const { repo, slug } of results) { - if (slug) { - next.set(slug, [...(next.get(slug) ?? []), repo]) + for (const key of slugResolutionInFlight.keys()) { + if (!liveKeys.has(key)) { + invalidateSlugResolution(key) } } + // Why: repo fleets can be large; only a fixed number of IPC/RPC slug probes + // should own promises and provider response buffers at once. + const slugs = await resolveRepoSlugsWithFixedWorkers( + repos, + (repo) => resolver(repo, settingsForRepoOwner(repo, settings)), + signal + ) + const next = indexResolvedRepoSlugs(repos, slugs) return { index: next, retryDelayMs: nextRepoSlugFailureRetryDelay(liveKeys) } } +function getSharedRepoSlugIndexBuild( + repos: Repo[], + settings: GlobalSettings | null | undefined, + resolver: RepoSlugResolver +): Promise { + if ( + sharedIndexBuild?.repos === repos && + sharedIndexBuild.settings === settings && + sharedIndexBuild.resolver === resolver + ) { + return sharedIndexBuild.promise + } + sharedIndexBuild?.controller.abort() + const controller = new AbortController() + const promise = buildIndex(repos, settings, resolver, controller.signal) + const entry = { repos, settings, resolver, controller, promise } + sharedIndexBuild = entry + const release = (): void => { + if (sharedIndexBuild === entry) { + sharedIndexBuild = undefined + } + } + void promise.then(release, release) + return promise +} + +/** @internal - shared-build regression coverage only. */ +export function buildSharedRepoSlugIndexForTests( + repos: Repo[], + resolver: RepoSlugResolver +): Promise { + return getSharedRepoSlugIndexBuild(repos, null, resolver).then((result) => result.index) +} + export type RepoSlugIndexState = { lookupSlug: (slug: string | null | undefined, host?: string) => Repo[] ready: boolean @@ -188,16 +269,19 @@ export function useRepoSlugIndex(): RepoSlugIndexState { const gen = ++generationRef.current let retryTimer: ReturnType | undefined setReady(false) - void buildIndex(repos, settings).then(({ index: next, retryDelayMs }) => { - if (gen !== generationRef.current) { - return - } - setIndex(next) - setReady(true) - if (retryDelayMs !== null) { - retryTimer = setTimeout(() => setRetryGeneration((value) => value + 1), retryDelayMs) - } - }) + void getSharedRepoSlugIndexBuild(repos, settings, resolveRepoSlug).then( + ({ index: next, retryDelayMs }) => { + if (gen !== generationRef.current) { + return + } + setIndex(next) + setReady(true) + if (retryDelayMs !== null) { + retryTimer = setTimeout(() => setRetryGeneration((value) => value + 1), retryDelayMs) + } + }, + () => {} + ) return () => { generationRef.current += 1 if (retryTimer) { diff --git a/src/renderer/src/lib/repo-slug-resolution-pool.test.ts b/src/renderer/src/lib/repo-slug-resolution-pool.test.ts new file mode 100644 index 00000000000..f5dfaa90b20 --- /dev/null +++ b/src/renderer/src/lib/repo-slug-resolution-pool.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getRepoSlugResolutionPoolStateForTests, + MAX_REPO_SLUG_RESOLUTION_WAITERS, + REPO_SLUG_RESOLUTION_CONCURRENCY, + resolveRepoSlugsWithFixedWorkers +} from './repo-slug-resolution-pool' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + return { + promise: new Promise((nextResolve) => { + resolve = nextResolve + }), + resolve + } +} + +async function occupyAllSlots(gate: Promise): Promise<(string | null)[]> { + return resolveRepoSlugsWithFixedWorkers( + Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY }, (_, index) => index), + async (index) => { + await gate + return `owner/repo-${index}` + } + ) +} + +describe('repo slug resolution pool', () => { + it('rejects one-over saturation without retaining another waiter', async () => { + const activeGate = deferred() + const active = occupyAllSlots(activeGate.promise) + await vi.waitFor(() => + expect(getRepoSlugResolutionPoolStateForTests().active).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) + ) + + const controllers = Array.from( + { length: MAX_REPO_SLUG_RESOLUTION_WAITERS }, + () => new AbortController() + ) + const queued = controllers.map((controller, index) => + resolveRepoSlugsWithFixedWorkers( + [index], + async () => `owner/queued-${index}`, + controller.signal + ) + ) + await vi.waitFor(() => + expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe( + MAX_REPO_SLUG_RESOLUTION_WAITERS + ) + ) + + await expect( + resolveRepoSlugsWithFixedWorkers([0], async () => 'owner/overflow') + ).rejects.toThrow('Too many queued repo slug resolutions.') + expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe(MAX_REPO_SLUG_RESOLUTION_WAITERS) + + controllers.forEach((controller) => controller.abort()) + await expect(Promise.allSettled(queued)).resolves.toSatisfy((results) => + results.every((result) => result.status === 'rejected') + ) + expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ + active: REPO_SLUG_RESOLUTION_CONCURRENCY, + waiters: 0 + }) + + activeGate.resolve() + await active + expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ active: 0, waiters: 0 }) + }) + + it('releases an aborted waiter so later work can use its queue slot', async () => { + const activeGate = deferred() + const active = occupyAllSlots(activeGate.promise) + await vi.waitFor(() => + expect(getRepoSlugResolutionPoolStateForTests().active).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) + ) + + const cancelledResolver = vi.fn(async () => 'owner/cancelled') + const controller = new AbortController() + const cancelled = resolveRepoSlugsWithFixedWorkers([0], cancelledResolver, controller.signal) + await vi.waitFor(() => expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe(1)) + controller.abort() + await expect(cancelled).rejects.toThrow('Repo slug index resolution was cancelled.') + expect(cancelledResolver).not.toHaveBeenCalled() + expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe(0) + + const replacementResolver = vi.fn(async () => 'owner/replacement') + const replacement = resolveRepoSlugsWithFixedWorkers([0], replacementResolver) + await vi.waitFor(() => expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe(1)) + activeGate.resolve() + + await expect(replacement).resolves.toEqual(['owner/replacement']) + await active + expect(replacementResolver).toHaveBeenCalledOnce() + expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ active: 0, waiters: 0 }) + }) + + it('removes partially admitted workers when their build hits the waiter cap', async () => { + const activeGate = deferred() + const active = occupyAllSlots(activeGate.promise) + await vi.waitFor(() => + expect(getRepoSlugResolutionPoolStateForTests().active).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) + ) + const controllers = Array.from( + { length: MAX_REPO_SLUG_RESOLUTION_WAITERS - 1 }, + () => new AbortController() + ) + const queued = controllers.map((controller, index) => + resolveRepoSlugsWithFixedWorkers( + [index], + async () => `owner/queued-${index}`, + controller.signal + ) + ) + await vi.waitFor(() => + expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe( + MAX_REPO_SLUG_RESOLUTION_WAITERS - 1 + ) + ) + + await expect( + resolveRepoSlugsWithFixedWorkers( + Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY }, (_, index) => index), + async (index) => `owner/overflow-${index}` + ) + ).rejects.toThrow('Too many queued repo slug resolutions.') + expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe( + MAX_REPO_SLUG_RESOLUTION_WAITERS - 1 + ) + + controllers.forEach((controller) => controller.abort()) + await Promise.allSettled(queued) + activeGate.resolve() + await active + expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ active: 0, waiters: 0 }) + }) + + it('releases a failed active slot and runs the oldest queued worker', async () => { + const failureGate = deferred() + const activeResolvers = Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY }, (_, index) => + resolveRepoSlugsWithFixedWorkers([index], async () => { + await failureGate.promise + if (index === 0) { + throw new Error('resolution failed') + } + return `owner/active-${index}` + }) + ) + await vi.waitFor(() => + expect(getRepoSlugResolutionPoolStateForTests().active).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) + ) + const queuedResolver = vi.fn(async () => 'owner/queued') + const queued = resolveRepoSlugsWithFixedWorkers([0], queuedResolver) + await vi.waitFor(() => expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe(1)) + + failureGate.resolve() + const activeResults = await Promise.allSettled(activeResolvers) + await expect(queued).resolves.toEqual(['owner/queued']) + + expect(activeResults.filter((result) => result.status === 'rejected')).toHaveLength(1) + expect(queuedResolver).toHaveBeenCalledOnce() + expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ active: 0, waiters: 0 }) + }) + + it('removes sibling waiters when one worker in their build fails', async () => { + const activeGates = Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY }, deferred) + const active = activeGates.map((gate, index) => + resolveRepoSlugsWithFixedWorkers([index], async () => { + await gate.promise + return `owner/active-${index}` + }) + ) + await vi.waitFor(() => + expect(getRepoSlugResolutionPoolStateForTests().active).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) + ) + + const queuedResolver = vi.fn(async (index: number) => { + if (index === 0) { + throw new Error('queued resolution failed') + } + return `owner/queued-${index}` + }) + const queued = resolveRepoSlugsWithFixedWorkers( + Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY }, (_, index) => index), + queuedResolver + ) + await vi.waitFor(() => + expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe( + REPO_SLUG_RESOLUTION_CONCURRENCY + ) + ) + + activeGates[0].resolve() + await expect(queued).rejects.toThrow('queued resolution failed') + expect(queuedResolver).toHaveBeenCalledTimes(1) + expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ + active: REPO_SLUG_RESOLUTION_CONCURRENCY - 1, + waiters: 0 + }) + + activeGates.slice(1).forEach((gate) => gate.resolve()) + await Promise.all(active) + expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ active: 0, waiters: 0 }) + }) +}) diff --git a/src/renderer/src/lib/repo-slug-resolution-pool.ts b/src/renderer/src/lib/repo-slug-resolution-pool.ts new file mode 100644 index 00000000000..8e4b891b2ab --- /dev/null +++ b/src/renderer/src/lib/repo-slug-resolution-pool.ts @@ -0,0 +1,126 @@ +export const REPO_SLUG_RESOLUTION_CONCURRENCY = 8 +export const MAX_REPO_SLUG_RESOLUTION_WAITERS = 64 + +let activeResolutions = 0 +const slotWaiters = new Set<() => void>() +const CANCELLED_RESOLUTION = Symbol('cancelled repo slug resolution') +const REJECTED_RESOLUTION = Symbol('rejected repo slug resolution') + +type SlotAdmission = 'acquired' | 'cancelled' | 'rejected' + +function acquireResolutionSlot(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve('cancelled') + } + if (activeResolutions < REPO_SLUG_RESOLUTION_CONCURRENCY) { + activeResolutions += 1 + return Promise.resolve('acquired') + } + if (slotWaiters.size >= MAX_REPO_SLUG_RESOLUTION_WAITERS) { + return Promise.resolve('rejected') + } + return new Promise((resolve) => { + const resume = (): void => { + signal?.removeEventListener('abort', cancel) + resolve('acquired') + } + const cancel = (): void => { + if (slotWaiters.delete(resume)) { + resolve('cancelled') + } + } + slotWaiters.add(resume) + signal?.addEventListener('abort', cancel, { once: true }) + }) +} + +function releaseResolutionSlot(): void { + activeResolutions -= 1 + const next = slotWaiters.values().next().value + if (next) { + slotWaiters.delete(next) + activeResolutions += 1 + next() + } +} + +async function resolveWithSharedSlot( + resolver: () => Promise, + onFailure: () => void, + signal?: AbortSignal +): Promise { + const admission = await acquireResolutionSlot(signal) + if (admission === 'cancelled') { + return CANCELLED_RESOLUTION + } + if (admission === 'rejected') { + return REJECTED_RESOLUTION + } + if (signal?.aborted) { + releaseResolutionSlot() + return CANCELLED_RESOLUTION + } + try { + return await resolver() + } catch (error) { + onFailure() + throw error + } finally { + releaseResolutionSlot() + } +} + +export async function resolveRepoSlugsWithFixedWorkers( + repos: readonly T[], + resolver: (repo: T) => Promise, + signal?: AbortSignal +): Promise<(string | null)[]> { + const results = Array(repos.length) + let nextIndex = 0 + const buildController = new AbortController() + const cancelBuild = (): void => buildController.abort() + if (signal?.aborted) { + buildController.abort() + } else { + signal?.addEventListener('abort', cancelBuild, { once: true }) + } + const worker = async (): Promise => { + while (nextIndex < repos.length && !buildController.signal.aborted) { + const index = nextIndex + nextIndex += 1 + const slug = await resolveWithSharedSlot( + () => resolver(repos[index]), + () => buildController.abort(), + buildController.signal + ) + if (slug === CANCELLED_RESOLUTION) { + return + } + if (slug === REJECTED_RESOLUTION) { + buildController.abort() + throw new Error('Too many queued repo slug resolutions.') + } + results[index] = slug + } + } + const workerCount = Math.min(REPO_SLUG_RESOLUTION_CONCURRENCY, repos.length) + try { + await Promise.all(Array.from({ length: workerCount }, worker)) + if (signal?.aborted) { + throw new Error('Repo slug index resolution was cancelled.') + } + return results + } catch (error) { + buildController.abort() + throw error + } finally { + signal?.removeEventListener('abort', cancelBuild) + } +} + +export function getRepoSlugResolutionPoolStateForTests(): { + active: number + waiters: number +} { + return { active: activeResolutions, waiters: slotWaiters.size } +} diff --git a/src/renderer/src/lib/transient-agent-status-clear-retention.test.ts b/src/renderer/src/lib/transient-agent-status-clear-retention.test.ts new file mode 100644 index 00000000000..d7be65c3b99 --- /dev/null +++ b/src/renderer/src/lib/transient-agent-status-clear-retention.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + retainTransientAgentStatusClearedConnection, + TransientAgentStatusClearRegistry, + TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS, + TRANSIENT_AGENT_STATUS_CLEAR_MAX_ID_UTF8_BYTES +} from './transient-agent-status-clear-retention' + +describe('transient agent status clear retention', () => { + it('keeps the newest watermark for an ordinary connection', () => { + const registry = new TransientAgentStatusClearRegistry() + + expect(registry.remember('ssh-a', 20)).toBe(20) + expect(registry.remember('ssh-a', 10)).toBe(20) + expect(registry.get('ssh-a')).toBe(20) + }) + + it('caps connection keys and fails closed for an evicted watermark', () => { + const registry = new TransientAgentStatusClearRegistry() + for (let index = 0; index <= TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS; index += 1) { + registry.remember(`ssh-${index}`, index + 1) + } + + expect(registry.evidence().connections).toBe(TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS) + expect(registry.get('ssh-0')).toBe(1) + expect(registry.get('never-seen')).toBe(1) + }) + + it('does not retain an oversized id and preserves its cutoff as a fail-closed floor', () => { + const registry = new TransientAgentStatusClearRegistry() + const oversized = 'x'.repeat(TRANSIENT_AGENT_STATUS_CLEAR_MAX_ID_UTF8_BYTES + 1) + + expect(registry.remember(oversized, 50)).toBeNull() + expect(registry.evidence()).toEqual({ + connections: 0, + idBytes: 0, + overflowWatermark: 50 + }) + expect(registry.get(oversized)).toBe(50) + }) + + it('caps the store routing blocks while retaining the newest connection', () => { + let retained: Record = {} + for (let index = 0; index <= TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS; index += 1) { + retained = retainTransientAgentStatusClearedConnection(retained, `ssh-${index}`) + } + + expect(Object.keys(retained)).toHaveLength(TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS) + expect(retained['ssh-0']).toBeUndefined() + expect(retained[`ssh-${TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS}`]).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/transient-agent-status-clear-retention.ts b/src/renderer/src/lib/transient-agent-status-clear-retention.ts new file mode 100644 index 00000000000..5e604035084 --- /dev/null +++ b/src/renderer/src/lib/transient-agent-status-clear-retention.ts @@ -0,0 +1,103 @@ +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' + +export const TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS = 1024 +export const TRANSIENT_AGENT_STATUS_CLEAR_MAX_ID_UTF8_BYTES = 1024 +export const TRANSIENT_AGENT_STATUS_CLEAR_MAX_TOTAL_ID_UTF8_BYTES = 1024 * 1024 + +type WatermarkEntry = { + watermark: number + idBytes: number +} + +export class TransientAgentStatusClearRegistry { + private readonly watermarks = new Map() + private retainedIdBytes = 0 + private overflowWatermark = Number.NEGATIVE_INFINITY + + remember(connectionId: string, clearedAt: number): number | null { + if (!Number.isFinite(clearedAt)) { + return null + } + const measured = measureUtf8ByteLength(connectionId, { + stopAfterBytes: TRANSIENT_AGENT_STATUS_CLEAR_MAX_ID_UTF8_BYTES + }) + if (connectionId.length === 0 || measured.exceededLimit) { + this.overflowWatermark = Math.max(this.overflowWatermark, clearedAt) + return null + } + + const previous = this.watermarks.get(connectionId) + if (previous) { + const watermark = Math.max(previous.watermark, clearedAt, this.overflowWatermark) + this.watermarks.delete(connectionId) + this.watermarks.set(connectionId, { ...previous, watermark }) + return watermark + } + + while ( + this.watermarks.size >= TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS || + this.retainedIdBytes + measured.byteLength > + TRANSIENT_AGENT_STATUS_CLEAR_MAX_TOTAL_ID_UTF8_BYTES + ) { + const oldestId = this.watermarks.keys().next().value + if (oldestId === undefined) { + this.overflowWatermark = Math.max(this.overflowWatermark, clearedAt) + return null + } + const oldest = this.watermarks.get(oldestId) + this.watermarks.delete(oldestId) + this.retainedIdBytes -= oldest?.idBytes ?? 0 + this.overflowWatermark = Math.max( + this.overflowWatermark, + oldest?.watermark ?? Number.NEGATIVE_INFINITY + ) + } + + const watermark = Math.max(clearedAt, this.overflowWatermark) + this.watermarks.set(connectionId, { watermark, idBytes: measured.byteLength }) + this.retainedIdBytes += measured.byteLength + return watermark + } + + get(connectionId: string): number | undefined { + const entry = this.watermarks.get(connectionId) + return entry?.watermark ?? finiteOrUndefined(this.overflowWatermark) + } + + evidence(): { connections: number; idBytes: number; overflowWatermark?: number } { + return { + connections: this.watermarks.size, + idBytes: this.retainedIdBytes, + ...(Number.isFinite(this.overflowWatermark) + ? { overflowWatermark: this.overflowWatermark } + : {}) + } + } +} + +export function retainTransientAgentStatusClearedConnection( + current: Record, + connectionId: string +): Record { + if ( + Object.prototype.hasOwnProperty.call(current, connectionId) || + connectionId.length === 0 || + measureUtf8ByteLength(connectionId, { + stopAfterBytes: TRANSIENT_AGENT_STATUS_CLEAR_MAX_ID_UTF8_BYTES + }).exceededLimit + ) { + return current + } + const next = { ...current } + const keys = Object.keys(next) + const removeCount = Math.max(0, keys.length - TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS + 1) + for (let index = 0; index < removeCount; index += 1) { + delete next[keys[index]] + } + next[connectionId] = true + return next +} + +function finiteOrUndefined(value: number): number | undefined { + return Number.isFinite(value) ? value : undefined +} diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index d86fc3ba927..49dc74e96a8 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -10,10 +10,52 @@ import { patchWorkspaceSessionByHost, persistWorkspaceSessionByHost, persistWorkspaceSessionByHostSync, + WORKSPACE_SESSION_HOST_IO_CONCURRENCY, type HostPersistenceState } from './workspace-session-host-persistence' describe('fetchWorkspaceSessionFromHosts', () => { + it.each([ + ['at the limit', WORKSPACE_SESSION_HOST_IO_CONCURRENCY], + ['above the limit', WORKSPACE_SESSION_HOST_IO_CONCURRENCY + 1] + ])('bounds runtime host partition reads %s', async (_, count) => { + let active = 0 + let peak = 0 + let started = 0 + const releases: (() => void)[] = [] + const get = vi.fn((hostId?: string) => { + if (!hostId) { + return Promise.resolve(getDefaultWorkspaceSession()) + } + started++ + active++ + peak = Math.max(peak, active) + return new Promise((resolve) => { + releases.push(() => { + active-- + resolve(getDefaultWorkspaceSession()) + }) + }) + }) + const hostIds = Array.from( + { length: count }, + (_, index) => `runtime:env-${index}` as `runtime:${string}` + ) + + const read = fetchWorkspaceSessionFromHosts({ get }, [], hostIds) + await vi.waitFor(() => + expect(started).toBe(Math.min(count, WORKSPACE_SESSION_HOST_IO_CONCURRENCY)) + ) + if (count > WORKSPACE_SESSION_HOST_IO_CONCURRENCY) { + releases.shift()?.() + await vi.waitFor(() => expect(started).toBe(count)) + } + releases.splice(0).forEach((release) => release()) + await read + + expect(peak).toBe(Math.min(count, WORKSPACE_SESSION_HOST_IO_CONCURRENCY)) + }) + it('reads saved runtime host partitions before runtime repos are loaded', async () => { const worktreeId = 'remote-repo::/srv/remote-wt' const localSession: WorkspaceSessionState = { diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 35c2dfc3fed..d0355ef4ebb 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -7,6 +7,11 @@ import { } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' +import { + forEachWithConcurrency, + mapSettledWithConcurrency, + mapWithConcurrency +} from '../../../shared/map-with-concurrency' import { mergeWorkspaceSessionsFromHosts, splitWorkspaceSessionByHost, @@ -47,6 +52,8 @@ export type WorkspaceSessionHostSnapshot = { hostId?: ExecutionHostId } +export const WORKSPACE_SESSION_HOST_IO_CONCURRENCY = 4 + const WORKSPACE_SESSION_KEYED_FIELDS = [ 'tabsByWorktree', 'openFilesByWorktree', @@ -225,12 +232,16 @@ export function patchWorkspaceSessionByHost( ) const local = (slices[LOCAL_EXECUTION_HOST_ID] ?? patch) as WorkspaceSessionPatch const localWrite = api.patch(local) - for (const [hostId, slice] of nonLocalEntries(slices)) { - // Why: a failed runtime-partition write must not reject the local chain. - void api.patch(slice as WorkspaceSessionPatch, hostId).catch((err) => { - console.warn(`[session] host partition patch failed for ${hostId}:`, err) - }) - } + void forEachWithConcurrency( + nonLocalEntries(slices), + WORKSPACE_SESSION_HOST_IO_CONCURRENCY, + async ([hostId, slice]) => { + // Why: a failed runtime-partition write must not reject the local chain. + await api.patch(slice as WorkspaceSessionPatch, hostId).catch((err) => { + console.warn(`[session] host partition patch failed for ${hostId}:`, err) + }) + } + ) return localWrite } @@ -242,12 +253,19 @@ export async function persistWorkspaceSessionByHost( payload: WorkspaceSessionState, state: HostPersistenceState ): Promise { - const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state)) - const writes: Promise[] = [api.set(slices[LOCAL_EXECUTION_HOST_ID] ?? payload)] - for (const [hostId, slice] of nonLocalEntries(slices)) { - writes.push(api.set(slice, hostId)) + const snapshots = buildWorkspaceSessionHostSnapshots(payload, state) + const writes = await mapSettledWithConcurrency( + snapshots, + WORKSPACE_SESSION_HOST_IO_CONCURRENCY, + ({ state: snapshot, hostId }) => + hostId === undefined ? api.set(snapshot) : api.set(snapshot, hostId) + ) + const failedWrite = writes.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + if (failedWrite) { + throw failedWrite.reason } - await Promise.all(writes) await api.flush() } @@ -318,14 +336,16 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners( ...listKnownRuntimeHostIds(repos), ...additionalRuntimeHostIds ]) - await Promise.all( - [...runtimeHostIds].map(async (hostId) => { + await mapWithConcurrency( + [...runtimeHostIds], + WORKSPACE_SESSION_HOST_IO_CONCURRENCY, + async (hostId) => { try { slices[hostId] = await api.get(hostId) } catch (err) { console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) } - }) + } ) return { session: mergeWorkspaceSessionsFromHosts(slices), diff --git a/src/renderer/src/runtime/web-session-snapshot-recovery-pool.test.ts b/src/renderer/src/runtime/web-session-snapshot-recovery-pool.test.ts new file mode 100644 index 00000000000..39e9c3b47b4 --- /dev/null +++ b/src/renderer/src/runtime/web-session-snapshot-recovery-pool.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { + mapWebSessionSnapshotRecoveries, + WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY +} from './web-session-snapshot-recovery-pool' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + return { + promise: new Promise((nextResolve) => { + resolve = nextResolve + }), + resolve + } +} + +describe('web session snapshot recovery pool', () => { + it.each([ + ['at the limit', WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY], + ['above the limit', WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY + 1] + ])('bounds recovery concurrency %s', async (_label, count) => { + const releases = Array.from({ length: count }, deferred) + let active = 0 + let peak = 0 + let started = 0 + const recover = vi.fn(async (value: number) => { + const release = releases[started] + started += 1 + active += 1 + peak = Math.max(peak, active) + await release.promise + active -= 1 + return value * 2 + }) + + const recovery = mapWebSessionSnapshotRecoveries( + Array.from({ length: count }, (_, index) => index), + recover + ) + await vi.waitFor(() => + expect(started).toBe(Math.min(count, WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY)) + ) + if (count > WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY) { + releases[0].resolve() + await vi.waitFor(() => expect(started).toBe(count)) + } + releases.forEach(({ resolve }) => resolve()) + + await expect(recovery).resolves.toEqual(Array.from({ length: count }, (_, index) => index * 2)) + expect(peak).toBe(Math.min(count, WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY)) + }) +}) diff --git a/src/renderer/src/runtime/web-session-snapshot-recovery-pool.ts b/src/renderer/src/runtime/web-session-snapshot-recovery-pool.ts new file mode 100644 index 00000000000..d44c4c2e9c1 --- /dev/null +++ b/src/renderer/src/runtime/web-session-snapshot-recovery-pool.ts @@ -0,0 +1,10 @@ +import { mapWithConcurrency } from '../../../shared/map-with-concurrency' + +export const WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY = 4 + +export function mapWebSessionSnapshotRecoveries( + snapshots: readonly T[], + recover: (snapshot: T, index: number) => Promise +): Promise { + return mapWithConcurrency(snapshots, WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY, recover) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index b2049c38936..41779986011 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -2364,7 +2364,7 @@ describe('applyWebSessionTabsSnapshot', () => { const existingTabId = toWebTerminalSurfaceTabId('host-tab-1') const newTabId = toWebTerminalSurfaceTabId('host-tab-2') // Simulate createWebRuntimeSessionTerminal recording focus intent for the new tab. - recordWebSessionFocusIntent({ environmentId: ENV }, WT, 'host-tab-2') + recordWebSessionFocusIntent({ environmentId: ENV }, WT, `host-tab-2::${SECOND_LEAF_ID}`) const existingUnifiedTab: Tab = { id: existingTabId, entityId: existingTabId, @@ -2457,168 +2457,6 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.groupsByWorktree?.[WT]?.[0]?.activeTabId).toBe(newTabId) }) - it('replays a snapshot that beat the RPC response and focuses the exact adopted leaf', () => { - const mirroredTabId = toWebTerminalSurfaceTabId('host-tab-1') - const root = { - type: 'split' as const, - direction: 'horizontal' as const, - first: { type: 'leaf' as const, leafId: LEAF_ID }, - second: { type: 'leaf' as const, leafId: SECOND_LEAF_ID } - } - const currentLayout = { - root, - activeLeafId: SECOND_LEAF_ID, - expandedLeafId: SECOND_LEAF_ID, - ptyIdsByLeafId: { - [LEAF_ID]: 'remote:web-env-1@@terminal-1', - [SECOND_LEAF_ID]: 'remote:web-env-1@@terminal-2' - } - } - const state = makeState({ - activeTabId: mirroredTabId, - activeTabIdByWorktree: { [WT]: mirroredTabId }, - tabsByWorktree: { - [WT]: [ - { - id: mirroredTabId, - ptyId: 'remote:web-env-1@@terminal-2', - worktreeId: WT, - title: 'shell', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: NOW - } - ] - }, - terminalLayoutsByTabId: { [mirroredTabId]: currentLayout } - }) - const snapshot = makeSnapshot( - [ - { - type: 'terminal', - id: `host-tab-1::${LEAF_ID}`, - title: 'codex', - parentTabId: 'host-tab-1', - leafId: LEAF_ID, - parentLayout: currentLayout, - isActive: false, - status: 'ready', - terminal: 'terminal-1' - }, - { - type: 'terminal', - id: `host-tab-1::${SECOND_LEAF_ID}`, - title: 'shell', - parentTabId: 'host-tab-1', - leafId: SECOND_LEAF_ID, - parentLayout: currentLayout, - isActive: true, - status: 'ready', - terminal: 'terminal-2' - } - ], - { - tabGroups: [ - { - id: 'host-group-1', - activeTabId: 'host-tab-1', - tabOrder: ['host-tab-1'] - } - ] - } - ) - - const subscriptionPatch = applyFreshWebSessionTabsSnapshot(state, snapshot, ENV, NOW) - const afterSubscription = { - ...state, - ...(subscriptionPatch as Partial) - } - expect(afterSubscription.terminalLayoutsByTabId[mirroredTabId]?.activeLeafId).toBe( - SECOND_LEAF_ID - ) - - recordWebSessionFocusIntent({ environmentId: ENV }, WT, 'host-tab-1', LEAF_ID) - acceptReplayedWebSessionTabsSnapshot(ENV, WT) - const replayPatch = applyFreshWebSessionTabsSnapshot( - afterSubscription, - snapshot, - ENV, - NOW + 10 - ) as Partial - - expect(replayPatch.tabsByWorktree?.[WT]?.[0]?.ptyId).toBe('remote:web-env-1@@terminal-1') - expect(replayPatch.terminalLayoutsByTabId?.[mirroredTabId]).toMatchObject({ - activeLeafId: LEAF_ID, - expandedLeafId: LEAF_ID - }) - }) - - it('retains exact-leaf focus intent when a split sibling publishes first', () => { - const mirroredTabId = toWebTerminalSurfaceTabId('host-tab-1') - const siblingPtyId = 'remote:web-env-1@@terminal-2' - const state = makeState({ - activeTabId: mirroredTabId, - activeTabIdByWorktree: { [WT]: mirroredTabId }, - tabsByWorktree: { - [WT]: [ - { - id: mirroredTabId, - ptyId: siblingPtyId, - worktreeId: WT, - title: 'shell', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: NOW - } - ] - } - }) - const sibling = { - type: 'terminal' as const, - id: `host-tab-1::${SECOND_LEAF_ID}`, - title: 'shell', - parentTabId: 'host-tab-1', - leafId: SECOND_LEAF_ID, - isActive: true, - status: 'ready' as const, - terminal: 'terminal-2' - } - recordWebSessionFocusIntent({ environmentId: ENV }, WT, 'host-tab-1', LEAF_ID) - - const partialPatch = applyWebSessionTabsSnapshot( - state, - makeSnapshot([sibling]), - ENV, - NOW - ) as Partial - expect(partialPatch.tabsByWorktree?.[WT]?.[0]?.ptyId).toBe(siblingPtyId) - - const afterPartial = { ...state, ...partialPatch } - const completePatch = applyWebSessionTabsSnapshot( - afterPartial, - makeSnapshot([ - sibling, - { - type: 'terminal', - id: `host-tab-1::${LEAF_ID}`, - title: 'codex', - parentTabId: 'host-tab-1', - leafId: LEAF_ID, - isActive: false, - status: 'ready', - terminal: 'terminal-1' - } - ]), - ENV, - NOW + 1 - ) as Partial - - expect(completePatch.tabsByWorktree?.[WT]?.[0]?.ptyId).toBe('remote:web-env-1@@terminal-1') - expect(completePatch.terminalLayoutsByTabId?.[mirroredTabId]?.activeLeafId).toBe(LEAF_ID) - }) - it('does not let repeated remote split status snapshots steal local pane focus', () => { const mirroredTabId = toWebTerminalSurfaceTabId('host-tab-1') const currentLayout = { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 022ba9503b5..75872851a64 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -83,6 +83,7 @@ import { resolveWebAgentSessionHandoff } from './web-agent-session-handoff' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { mapWebSessionSnapshotRecoveries } from './web-session-snapshot-recovery-pool' const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' @@ -347,6 +348,7 @@ function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeI replayableSessionTabsSnapshotByWorktree.delete(key) lastHostTerminalTabCountByWorktree.delete(key) clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId) + clearWebSessionFocusIntent({ environmentId }, worktreeId) clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId) clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId) clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) @@ -2734,16 +2736,17 @@ export function useWebSessionTabsSync(): void { console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') return } - const recovered = await Promise.all( - result.snapshots.map((snapshot) => - recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - snapshot, - environmentId - ) + const recovered = await mapWebSessionSnapshotRecoveries(result.snapshots, (snapshot) => + recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId ) ) - if (disposed) { + if ( + disposed || + getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision + ) { return } const applicable = recovered.filter( @@ -2789,17 +2792,19 @@ export function useWebSessionTabsSync(): void { const event = response.result as SessionTabsStreamEvent const replayed = isRuntimeSubscriptionReplayResponse(response) if (event.type === 'snapshots') { - void Promise.all( - event.snapshots.map((snapshot) => - recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - snapshot, - environmentId - ) + void mapWebSessionSnapshotRecoveries(event.snapshots, (snapshot) => + recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId ) ) .then((recovered) => { - if (!disposed) { + if ( + !disposed && + getRuntimeEnvironmentRevision(environmentId) === + expectedEnvironmentPairingRevision + ) { const applicable = recovered.filter( (snapshot): snapshot is RuntimeMobileSessionTabsResult => snapshot !== null ) @@ -2829,7 +2834,12 @@ export function useWebSessionTabsSync(): void { environmentId ) .then((recovered) => { - if (!disposed && recovered) { + if ( + !disposed && + recovered && + getRuntimeEnvironmentRevision(environmentId) === + expectedEnvironmentPairingRevision + ) { if (replayed) { acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) } @@ -2915,7 +2925,11 @@ export function useWebSessionTabsSync(): void { event, environmentId ) - if (disposed || !recovered) { + if ( + disposed || + !recovered || + getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision + ) { return } if (isRuntimeSubscriptionReplayResponse(response)) { diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.test.ts new file mode 100644 index 00000000000..e0ed83ed157 --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { WebSessionTerminalOrphanRecoveryAdmission } from './web-session-terminal-orphan-recovery-admission' + +function deferred() { + let resolve!: (value: T) => void + return { + promise: new Promise((nextResolve) => { + resolve = nextResolve + }), + resolve + } +} + +const LIMITS = { + maxActive: 2, + maxOwners: 3, + maxOwnerKeyBytes: 8, + maxTotalKeyBytes: 8, + maxRetainedBytes: 10 +} + +describe('web session terminal orphan recovery admission', () => { + it('bounds global concurrency and releases retained bytes after completion', async () => { + const admission = new WebSessionTerminalOrphanRecoveryAdmission(LIMITS) + const releases = [deferred(), deferred(), deferred()] + const results = releases.map((release, index) => + admission.schedule(String(index), 2, () => release.promise) + ) + + expect(admission.evidence()).toMatchObject({ + active: 2, + owners: 3, + queued: 1, + retainedBytes: 6 + }) + releases[0].resolve(10) + await expect(results[0]).resolves.toBe(10) + expect(admission.evidence()).toMatchObject({ active: 2, owners: 2, queued: 0 }) + releases[1].resolve(11) + releases[2].resolve(12) + await expect(Promise.all(results)).resolves.toEqual([10, 11, 12]) + expect(admission.evidence()).toEqual({ + active: 0, + owners: 0, + queued: 0, + retainedBytes: 0, + retainedKeyBytes: 0 + }) + }) + + it('coalesces a stalled owner to its latest pending snapshot', async () => { + const admission = new WebSessionTerminalOrphanRecoveryAdmission(LIMITS) + const active = deferred() + const first = admission.schedule('owner', 2, () => active.promise) + const superseded = admission.schedule('owner', 3, async () => 2) + const latest = admission.schedule('owner', 4, async () => 3) + + await expect(superseded).resolves.toBeNull() + expect(admission.evidence()).toMatchObject({ active: 1, owners: 1, retainedBytes: 6 }) + active.resolve(1) + await expect(first).resolves.toBe(1) + await expect(latest).resolves.toBe(3) + expect(admission.evidence().retainedBytes).toBe(0) + }) + + it('rejects owner, key, and aggregate payload overflow without running work', async () => { + const admission = new WebSessionTerminalOrphanRecoveryAdmission(LIMITS) + const active = deferred() + const first = admission.schedule('a', 6, () => active.promise) + + await expect(admission.schedule('oversized', 1, async () => 2)).resolves.toBeNull() + await expect(admission.schedule('b', 5, async () => 2)).resolves.toBeNull() + active.resolve(1) + await expect(first).resolves.toBe(1) + }) +}) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.ts new file mode 100644 index 00000000000..414c38415b0 --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.ts @@ -0,0 +1,187 @@ +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' + +export const WEB_SESSION_ORPHAN_RECOVERY_MAX_ACTIVE = 4 +export const WEB_SESSION_ORPHAN_RECOVERY_MAX_OWNERS = 64 +export const WEB_SESSION_ORPHAN_RECOVERY_MAX_OWNER_KEY_BYTES = 64 * 1024 +export const WEB_SESSION_ORPHAN_RECOVERY_MAX_TOTAL_KEY_BYTES = 1024 * 1024 +export const WEB_SESSION_ORPHAN_RECOVERY_MAX_RETAINED_BYTES = 32 * 1024 * 1024 + +type RecoveryAdmissionLimits = { + maxActive: number + maxOwners: number + maxOwnerKeyBytes: number + maxTotalKeyBytes: number + maxRetainedBytes: number +} + +type RecoveryJob = { + retainedBytes: number + run: () => Promise + resolve: (value: T | null) => void +} + +type RecoveryOwner = { + activeBytes: number + keyBytes: number + pending: RecoveryJob | null + queued: boolean + running: boolean +} + +const DEFAULT_LIMITS: RecoveryAdmissionLimits = { + maxActive: WEB_SESSION_ORPHAN_RECOVERY_MAX_ACTIVE, + maxOwners: WEB_SESSION_ORPHAN_RECOVERY_MAX_OWNERS, + maxOwnerKeyBytes: WEB_SESSION_ORPHAN_RECOVERY_MAX_OWNER_KEY_BYTES, + maxTotalKeyBytes: WEB_SESSION_ORPHAN_RECOVERY_MAX_TOTAL_KEY_BYTES, + maxRetainedBytes: WEB_SESSION_ORPHAN_RECOVERY_MAX_RETAINED_BYTES +} + +export class WebSessionTerminalOrphanRecoveryAdmission { + private readonly owners = new Map>() + private readonly readyOwnerKeys: string[] = [] + private active = 0 + private retainedBytes = 0 + private retainedKeyBytes = 0 + + constructor(private readonly limits: RecoveryAdmissionLimits = DEFAULT_LIMITS) { + for (const value of Object.values(limits)) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError('Orphan recovery admission limits must be positive integers') + } + } + } + + schedule(key: string, retainedBytes: number, run: () => Promise): Promise { + if (!Number.isSafeInteger(retainedBytes) || retainedBytes < 0) { + return Promise.resolve(null) + } + const keyMeasurement = measureUtf8ByteLength(key, { + stopAfterBytes: this.limits.maxOwnerKeyBytes + }) + if (key.length === 0 || keyMeasurement.exceededLimit) { + return Promise.resolve(null) + } + + const existing = this.owners.get(key) + if (existing) { + const replacedBytes = existing.pending?.retainedBytes ?? 0 + if (retainedBytes > this.limits.maxRetainedBytes - (this.retainedBytes - replacedBytes)) { + return Promise.resolve(null) + } + return new Promise((resolve) => { + if (existing.pending) { + this.retainedBytes -= existing.pending.retainedBytes + existing.pending.resolve(null) + } + existing.pending = { retainedBytes, run, resolve } + this.retainedBytes += retainedBytes + this.enqueue(key, existing) + }) + } + + if ( + this.owners.size >= this.limits.maxOwners || + keyMeasurement.byteLength > this.limits.maxTotalKeyBytes - this.retainedKeyBytes || + retainedBytes > this.limits.maxRetainedBytes - this.retainedBytes + ) { + return Promise.resolve(null) + } + + return new Promise((resolve) => { + const owner: RecoveryOwner = { + activeBytes: 0, + keyBytes: keyMeasurement.byteLength, + pending: { retainedBytes, run, resolve }, + queued: false, + running: false + } + this.owners.set(key, owner) + this.retainedBytes += retainedBytes + this.retainedKeyBytes += keyMeasurement.byteLength + this.enqueue(key, owner) + }) + } + + evidence(): { + active: number + owners: number + queued: number + retainedBytes: number + retainedKeyBytes: number + } { + return { + active: this.active, + owners: this.owners.size, + queued: this.readyOwnerKeys.length, + retainedBytes: this.retainedBytes, + retainedKeyBytes: this.retainedKeyBytes + } + } + + reset(): void { + this.readyOwnerKeys.length = 0 + for (const [key, owner] of this.owners) { + owner.queued = false + if (owner.pending) { + this.retainedBytes -= owner.pending.retainedBytes + owner.pending.resolve(null) + owner.pending = null + } + if (!owner.running) { + this.owners.delete(key) + this.retainedKeyBytes -= owner.keyBytes + } + } + } + + private enqueue(key: string, owner: RecoveryOwner): void { + if (!owner.running && !owner.queued && owner.pending) { + owner.queued = true + this.readyOwnerKeys.push(key) + } + this.drain() + } + + private drain(): void { + while (this.active < this.limits.maxActive) { + const key = this.readyOwnerKeys.shift() + if (key === undefined) { + return + } + const owner = this.owners.get(key) + if (!owner || !owner.queued || owner.running || !owner.pending) { + continue + } + owner.queued = false + void this.run(key, owner) + } + } + + private async run(key: string, owner: RecoveryOwner): Promise { + const job = owner.pending + if (!job) { + return + } + owner.pending = null + owner.running = true + owner.activeBytes = job.retainedBytes + this.active += 1 + try { + job.resolve(await job.run()) + } catch { + job.resolve(null) + } finally { + this.active -= 1 + this.retainedBytes -= owner.activeBytes + owner.activeBytes = 0 + owner.running = false + if (owner.pending) { + this.enqueue(key, owner) + } else if (this.owners.get(key) === owner) { + this.owners.delete(key) + this.retainedKeyBytes -= owner.keyBytes + } + this.drain() + } + } +} diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts index 9f44597561d..2bf58527750 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts @@ -12,6 +12,8 @@ import { buildWebTerminalOrphanTopologyProposal, type WebTerminalOrphanTopologyState } from './web-session-terminal-orphan-topology' +import { measureMetadataValueBytes } from '../hooks/metadata-retention-measurement' +import { WebSessionTerminalOrphanRecoveryAdmission } from './web-session-terminal-orphan-recovery-admission' type TerminalOrphanRecoveryState = WebTerminalOrphanTopologyState & { tabsByWorktree: Record @@ -24,7 +26,9 @@ type RuntimeCall = (args: { timeoutMs: number }) => Promise> -const inFlightRecoveryByWorktree = new Map>() +export const WEB_SESSION_ORPHAN_RECOVERY_MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024 +const recoveryAdmission = + new WebSessionTerminalOrphanRecoveryAdmission() function recoveryKey(environmentId: string, worktreeId: string): string { return `${environmentId}\0${worktreeId}` @@ -184,20 +188,18 @@ export function recoverWebSessionTerminalOrphansBeforeApply( call: RuntimeCall = (args) => window.api.runtimeEnvironments.call(args) ): Promise { const key = recoveryKey(environmentId, snapshot.worktree) - const existing = inFlightRecoveryByWorktree.get(key) - const recovery = (existing ?? Promise.resolve(null)) - .catch(() => null) - .then(() => recoverTerminalOrphans(state, snapshot, environmentId, call)) - .catch(() => null) - .finally(() => { - if (inFlightRecoveryByWorktree.get(key) === recovery) { - inFlightRecoveryByWorktree.delete(key) - } - }) - inFlightRecoveryByWorktree.set(key, recovery) - return recovery + const retainedBytes = measureMetadataValueBytes( + snapshot, + WEB_SESSION_ORPHAN_RECOVERY_MAX_SNAPSHOT_BYTES + ) + if (retainedBytes === null) { + return Promise.resolve(null) + } + return recoveryAdmission.schedule(key, retainedBytes, () => + recoverTerminalOrphans(state, snapshot, environmentId, call) + ) } export function clearWebSessionTerminalOrphanRecoveryForTests(): void { - inFlightRecoveryByWorktree.clear() + recoveryAdmission.reset() } diff --git a/src/renderer/src/startup/ssh-startup-reconnect-fanout.test.ts b/src/renderer/src/startup/ssh-startup-reconnect-fanout.test.ts new file mode 100644 index 00000000000..2e4bab2a16c --- /dev/null +++ b/src/renderer/src/startup/ssh-startup-reconnect-fanout.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SshConnectionState } from '../../../shared/ssh-types' +import { reconnectSshTargetsForRendererStartup } from './ssh-startup-reconnect' + +function connectedState(targetId: string): SshConnectionState { + return { + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + remotePlatform: 'linux' + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('SSH startup reconnect fanout', () => { + it('bounds a large target list and publishes every successful result', async () => { + const targetIds = Array.from({ length: 100 }, (_, index) => `ssh-${index}`) + let inFlight = 0 + let peak = 0 + const connect = vi.fn(async (targetId: string) => { + inFlight += 1 + peak = Math.max(peak, inFlight) + await Promise.resolve() + inFlight -= 1 + return connectedState(targetId) + }) + const publishState = vi.fn() + + const timedOut = await reconnectSshTargetsForRendererStartup({ + targetIds, + timeoutMs: 1_000, + connect, + publishState, + onFailure: vi.fn() + }) + + expect(peak).toBe(4) + expect(timedOut).toEqual([]) + expect(connect).toHaveBeenCalledTimes(targetIds.length) + expect(publishState).toHaveBeenCalledTimes(targetIds.length) + }) + + it('defers queued targets without extending the shared startup deadline', async () => { + vi.useFakeTimers() + const targetIds = Array.from({ length: 10 }, (_, index) => `ssh-${index}`) + const connect = vi.fn(() => new Promise(() => {})) + const result = reconnectSshTargetsForRendererStartup({ + targetIds, + timeoutMs: 1_000, + connect, + publishState: vi.fn(), + onFailure: vi.fn() + }) + + await vi.advanceTimersByTimeAsync(1_000) + + await expect(result).resolves.toEqual(targetIds) + expect(connect).toHaveBeenCalledTimes(4) + }) +}) diff --git a/src/renderer/src/startup/ssh-startup-reconnect.ts b/src/renderer/src/startup/ssh-startup-reconnect.ts index d312239a878..3a0c913a9f7 100644 --- a/src/renderer/src/startup/ssh-startup-reconnect.ts +++ b/src/renderer/src/startup/ssh-startup-reconnect.ts @@ -1,4 +1,7 @@ import type { SshConnectionState } from '../../../shared/ssh-types' +import { mapWithConcurrency } from '../../../shared/map-with-concurrency' + +const SSH_STARTUP_RECONNECT_CONCURRENCY = 4 export type SshStartupReconnectResult = { timedOut: boolean @@ -35,3 +38,35 @@ export async function reconnectSshTargetForRendererStartup(args: { } } } + +export async function reconnectSshTargetsForRendererStartup(args: { + targetIds: readonly string[] + timeoutMs: number + connect: (targetId: string) => Promise + publishState: (targetId: string, state: SshConnectionState) => void + onFailure: (targetId: string, error: unknown) => void +}): Promise { + const { targetIds, timeoutMs, connect, publishState, onFailure } = args + // Why: batching must not multiply the existing startup wait ceiling. + const deadline = Date.now() + timeoutMs + const results = await mapWithConcurrency( + targetIds, + SSH_STARTUP_RECONNECT_CONCURRENCY, + async (targetId) => { + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + onFailure(targetId, new Error('SSH reconnect timeout')) + return targetId + } + const result = await reconnectSshTargetForRendererStartup({ + targetId, + timeoutMs: remainingMs, + connect, + publishState, + onFailure + }) + return result.timedOut ? targetId : null + } + ) + return results.filter((targetId): targetId is string => targetId !== null) +} diff --git a/src/renderer/src/store/slices/agent-status-provider-session.test.ts b/src/renderer/src/store/slices/agent-status-provider-session.test.ts index 30262d079f0..d8169c49798 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session.test.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session.test.ts @@ -4,16 +4,6 @@ import type { AppState } from '../types' import { getProviderSessionClaimKey } from '../../lib/sleeping-agent-pane-ownership' import { createTestStore, makeTab } from './store-test-helpers' -const PI_COMPATIBLE_CASES = [ - { agent: 'pi' as const, label: 'Pi' }, - { agent: 'omp' as const, label: 'OMP' } -] - -function makePiCompatibleProviderSession(agent: 'pi' | 'omp') { - const session = { key: 'session_id' as const, id: `${agent}-session-1` } - return agent === 'pi' ? { ...session, transcriptPath: '/tmp/pi-session-1.jsonl' } : session -} - describe('recordAgentProviderSession', () => { it('preserves the root session while a child permission hook moves Codex to waiting', () => { const store = createTestStore() @@ -182,138 +172,140 @@ describe('recordAgentProviderSession', () => { ).toBeUndefined() }) - it.each(PI_COMPATIBLE_CASES)( - 'keeps a completed $label session resumable through manual worktree sleep', - async ({ agent, label }) => { - const store = createTestStore() - store.setState({ - tabsByWorktree: { - 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] - } - } as Partial) - const providerSession = makePiCompatibleProviderSession(agent) - - store - .getState() - .recordAgentProviderSession( - 'tab-1:leaf-1', - agent, - providerSession, - { updatedAt: 10 }, - { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } - ) - store - .getState() - .setAgentStatus( - 'tab-1:leaf-1', - { state: 'working', prompt: 'finish the task', agentType: agent }, - label, - { updatedAt: 20, stateStartedAt: 20 }, - { tabId: 'tab-1', worktreeId: 'wt-1' }, - { providerSession } - ) - store - .getState() - .setAgentStatus( - 'tab-1:leaf-1', - { state: 'done', prompt: 'finish the task', agentType: agent }, - label, - { updatedAt: 30, stateStartedAt: 30 }, - { tabId: 'tab-1', worktreeId: 'wt-1' }, - { providerSession } - ) - - expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']?.state).toBe('done') - const liveRecord = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] - expect(liveRecord).toMatchObject({ - agent, - providerSession, - connectionId: 'ssh-connection-1', - state: 'working', - origin: 'live' - }) - - store.getState().captureAllSleepingAgentSessions('periodic') - expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(liveRecord) - - await store.getState().shutdownWorktreeTerminals('wt-1', { - keepIdentifiers: true, - shutdownReason: 'manual-sleep', - sleepingPaneKeys: ['tab-1:leaf-1'] - }) - - expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']).toBeUndefined() - expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ - agent, - providerSession, - connectionId: 'ssh-connection-1', - state: 'working', - origin: 'worktree-sleep' - }) + it('keeps a completed Pi session resumable through manual worktree sleep', async () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + const providerSession = { + key: 'session_id' as const, + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' } - ) - it.each(PI_COMPATIBLE_CASES)( - 'keeps a completed $label session resumable through quit capture', - ({ agent, label }) => { - const store = createTestStore() - store.setState({ - tabsByWorktree: { - 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] - } - } as Partial) - const providerSession = makePiCompatibleProviderSession(agent) - - store - .getState() - .recordAgentProviderSession( - 'tab-1:leaf-1', - agent, - providerSession, - { updatedAt: 10 }, - { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } - ) - store - .getState() - .setAgentStatus( - 'tab-1:leaf-1', - { state: 'working', prompt: 'finish the task', agentType: agent }, - label, - { updatedAt: 20, stateStartedAt: 20 }, - { tabId: 'tab-1', worktreeId: 'wt-1' }, - { providerSession } - ) - store - .getState() - .setAgentStatus( - 'tab-1:leaf-1', - { state: 'done', prompt: 'finish the task', agentType: agent }, - label, - { updatedAt: 30, stateStartedAt: 30 }, - { tabId: 'tab-1', worktreeId: 'wt-1' }, - { providerSession } - ) - - store.getState().captureAllSleepingAgentSessions('periodic') - expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', providerSession, - connectionId: 'ssh-connection-1', - origin: 'live' - }) + { updatedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'finish the task', agentType: 'pi' }, + 'Pi', + { updatedAt: 20, stateStartedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'done', prompt: 'finish the task', agentType: 'pi' }, + 'Pi', + { updatedAt: 30, stateStartedAt: 30 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) - store.getState().captureAllSleepingAgentSessions('quit') + expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']?.state).toBe('done') + const liveRecord = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] + expect(liveRecord).toMatchObject({ + agent: 'pi', + providerSession, + connectionId: 'ssh-connection-1', + state: 'working', + origin: 'live' + }) - const quitRecord = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] - expect(quitRecord).toMatchObject({ - agent, - providerSession, - connectionId: 'ssh-connection-1', - state: 'working', - origin: 'quit' - }) + store.getState().captureAllSleepingAgentSessions('periodic') + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(liveRecord) - store.getState().captureAllSleepingAgentSessions('periodic') - expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(quitRecord) + await store.getState().shutdownWorktreeTerminals('wt-1', { + keepIdentifiers: true, + shutdownReason: 'manual-sleep', + sleepingPaneKeys: ['tab-1:leaf-1'] + }) + + expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']).toBeUndefined() + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + agent: 'pi', + providerSession, + connectionId: 'ssh-connection-1', + state: 'working', + origin: 'worktree-sleep' + }) + }) + + it('keeps a completed Pi session resumable through quit capture', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + const providerSession = { + key: 'session_id' as const, + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' } - ) + + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', + providerSession, + { updatedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'finish the task', agentType: 'pi' }, + 'Pi', + { updatedAt: 20, stateStartedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'done', prompt: 'finish the task', agentType: 'pi' }, + 'Pi', + { updatedAt: 30, stateStartedAt: 30 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + + store.getState().captureAllSleepingAgentSessions('periodic') + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + providerSession, + connectionId: 'ssh-connection-1', + origin: 'live' + }) + + store.getState().captureAllSleepingAgentSessions('quit') + + const quitRecord = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] + expect(quitRecord).toMatchObject({ + agent: 'pi', + providerSession, + connectionId: 'ssh-connection-1', + state: 'working', + origin: 'quit' + }) + + store.getState().captureAllSleepingAgentSessions('periodic') + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(quitRecord) + }) }) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 3c6ddb9d7af..44ff696a26a 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -38,7 +38,6 @@ import { isOrcaDispatchPrompt, orchestrationLabelsMatchLiveDispatch } from '@/lib/agent-row-primary-text' -import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/pi-compatible-live-recovery-record' import { resolveAgentPaneAuthorityKey, retireAgentPaneAuthorityAliases, @@ -46,6 +45,7 @@ import { transferAgentPaneAuthorityAlias } from './agent-pane-authority' import { createFreshnessScheduler } from './agent-status-freshness-scheduler' +import { retainTransientAgentStatusClearedConnection } from '@/lib/transient-agent-status-clear-retention' /** Snapshot of a finished/vanished agent status entry, kept so the dashboard and sidebar hover * keep showing the completion until the user clicks the worktree. `worktreeId` is stamped at @@ -597,6 +597,22 @@ function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean return entry.state === 'done' && entry.interrupted !== true } +function isCompletedPiWithLiveRecoveryRecord( + entry: AgentStatusEntry | undefined, + record: SleepingAgentSessionRecord | undefined +): record is SleepingAgentSessionRecord { + return Boolean( + entry?.state === 'done' && + entry.agentType === 'pi' && + entry.providerSession && + record?.agent === 'pi' && + record.origin === 'live' && + (!entry.worktreeId || entry.worktreeId === record.worktreeId) && + agentProviderSessionsEqual('pi', entry.providerSession, record.providerSession) && + getAgentResumeArgv('pi', record.providerSession) + ) +} + export function removeSleepingRecordsReplacedByManualWorktreeSleep( records: Record, worktreeId: string, @@ -643,8 +659,7 @@ export function collectSleepingAgentSessionRecordsForWorktree( if ( existing.worktreeId !== worktreeId || existing.origin !== 'live' || - (liveEntry !== undefined && - !isCompletedPiCompatibleAgentWithLiveRecoveryRecord(liveEntry, existing)) || + (liveEntry !== undefined && !isCompletedPiWithLiveRecoveryRecord(liveEntry, existing)) || (allowedPaneKeys && !allowedPaneKeys.has(existing.paneKey)) || !getAgentResumeArgv(existing.agent, existing.providerSession) ) { @@ -810,8 +825,7 @@ function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunc return { ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), agentArgs: config.agentArgs, - agentEnv: { ...config.agentEnv }, - ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}) + agentEnv: { ...config.agentEnv } } } @@ -822,11 +836,7 @@ function launchConfigsEqual( if (a === undefined || b === undefined) { return a === b } - if ( - a.agentCommand !== b.agentCommand || - a.agentArgs !== b.agentArgs || - a.ompResumeFilePath !== b.ompResumeFilePath - ) { + if (a.agentCommand !== b.agentCommand || a.agentArgs !== b.agentArgs) { return false } const aKeys = Object.keys(a.agentEnv) @@ -2226,8 +2236,11 @@ export const createAgentStatusSlice: StateCreator() @@ -1660,18 +1664,6 @@ export const createEditorSlice: StateCreator = (s resolveEditorOpenTargetGroupId(s, worktreeId, options?.targetGroupId) ?? undefined editorItemTargetGroupId = targetGroupId const activeResult = buildEditorActiveResult(s, worktreeId, id) - // Why: the renderer may mount asynchronously after the opening control - // receives DOM focus, so carry the user's explicit focus handoff by file. - const focusRequestUpdate = options?.focusEditor - ? { - pendingEditorFocusRequest: { - fileId: id, - worktreeId, - token: ++nextEditorFocusRequestToken - } - } - : {} - if (existing) { // If opening as non-preview, also pin the existing tab const updatedPreview = isPreview ? existing.isPreview : false @@ -1702,7 +1694,7 @@ export const createEditorSlice: StateCreator = (s refreshExternalSshProvenance || existing.fileContentReloadNonce !== fileContentReloadNonce if (!needsExistingUpdate) { - return { ...activeResult, ...focusRequestUpdate } + return activeResult } // Why: `readOnly` is intentionally NOT in this override map — it's sticky, so `...f` preserves the tab's own read-only state. return { @@ -1734,8 +1726,7 @@ export const createEditorSlice: StateCreator = (s } : f ), - ...activeResult, - ...focusRequestUpdate + ...activeResult } } @@ -1813,8 +1804,7 @@ export const createEditorSlice: StateCreator = (s recentlyClosedEditorTabsByWorktree: nextRecentlyClosed, recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds, ...previewTabBarUpdate, - ...activeResult, - ...focusRequestUpdate + ...activeResult } } } @@ -1854,11 +1844,10 @@ export const createEditorSlice: StateCreator = (s } ], ...tabBarUpdate, - ...activeResult, - ...focusRequestUpdate + ...activeResult } }) - void openWorkspaceEditorItem( + const editorItemViewStateId = openWorkspaceEditorItem( get(), editorItemFileId, editorItemWorktreeId, @@ -1867,6 +1856,17 @@ export const createEditorSlice: StateCreator = (s options?.preview ?? false, editorItemTargetGroupId ) + if (options?.focusEditor) { + set({ + pendingEditorFocusRequest: { + fileId: editorItemFileId, + worktreeId: editorItemWorktreeId, + viewStateId: editorItemViewStateId, + expiresAt: Date.now() + EDITOR_FOCUS_REQUEST_TTL_MS, + token: ++nextEditorFocusRequestToken + } + }) + } }, openNewMarkdownInActiveWorkspace: async (groupId) => { diff --git a/src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts b/src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts new file mode 100644 index 00000000000..77948ba5ed7 --- /dev/null +++ b/src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +// Mirrors the real report: one project name ("orca") set up on the local Mac and on a +// remote Orca server, where only the local repo row carries the user's chosen color. +const SHARED_PROJECT_ID = 'github:stablyai/orca' +const LOCAL_GREEN = '#22c55e' +const REMOTE_NEUTRAL = '#737373' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local/orca', + displayName: 'orca', + badgeColor: LOCAL_GREEN, + upstream: { owner: 'stablyai', repo: 'orca' }, + addedAt: 1 +} + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/srv/orca', + displayName: 'orca', + badgeColor: REMOTE_NEUTRAL, + upstream: { owner: 'stablyai', repo: 'orca' }, + addedAt: 1 +} + +const localProject: Project = { + id: SHARED_PROJECT_ID, + displayName: 'orca', + badgeColor: LOCAL_GREEN, + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 +} + +const remoteProject: Project = { + id: SHARED_PROJECT_ID, + displayName: 'orca', + badgeColor: REMOTE_NEUTRAL, + sourceRepoIds: ['remote-repo'], + createdAt: 2, + updatedAt: 2 +} + +function setup(projectId: string, repoId: string, path: string): ProjectHostSetup { + return { + id: `${repoId}-setup`, + projectId, + hostId: 'local', + repoId, + path, + displayName: 'orca', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } +} + +const reposList = vi.fn() +const reposUpdate = vi.fn() +const projectsList = vi.fn() +const listHostSetups = vi.fn() +const runtimeEnvironmentsList = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +function runtimeResult(method: string): unknown { + if (method === 'repo.list') { + return { repos: [remoteRepo] } + } + if (method === 'project.list') { + return { projects: [remoteProject] } + } + if (method === 'projectHostSetup.list') { + return { setups: [setup(SHARED_PROJECT_ID, 'remote-repo', '/srv/orca')] } + } + return {} +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + reposUpdate.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + runtimeEnvironmentsList.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + + reposList.mockResolvedValue([localRepo]) + reposUpdate.mockImplementation(({ updates }: { updates: Partial }) => ({ + ...localRepo, + ...updates + })) + projectsList.mockResolvedValue([localProject]) + listHostSetups.mockResolvedValue([setup(SHARED_PROJECT_ID, 'local-repo', '/local/orca')]) + runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'awin' }]) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => ({ + id: `rpc-${args.method}`, + ok: true, + result: runtimeResult(args.method), + _meta: { runtimeId: 'runtime-remote' } + })) + runtimeEnvironmentTransportCall.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + ) + + vi.stubGlobal('window', { + api: { + repos: { list: reposList, update: reposUpdate }, + projects: { list: projectsList, listHostSetups }, + projectGroups: { list: vi.fn().mockResolvedValue([]) }, + folderWorkspaces: { list: vi.fn().mockResolvedValue([]) }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + } + }, + dispatchEvent: vi.fn() + }) +}) + +function badgeColor(store: ReturnType): string | undefined { + return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID)?.badgeColor +} + +describe('shared project badge color across hosts', () => { + it('keeps the local color when a remote host shares the project name', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expect(badgeColor(store)).toBe(LOCAL_GREEN) + }) + + it('keeps the local color when a runtime-only refresh lands after startup', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect(badgeColor(store)).toBe(LOCAL_GREEN) + }) + + it('reflects a recolor of the local repo even while the remote host stays connected', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + const recolored = '#ec4899' + await store.getState().updateRepo('local-repo', { badgeColor: recolored }, { hostId: 'local' }) + + expect(store.getState().repos.find((repo) => repo.id === 'local-repo')?.badgeColor).toBe( + recolored + ) + expect(badgeColor(store)).toBe(recolored) + }) + + it('leaves a remote-only project showing its own host color', async () => { + reposList.mockResolvedValue([]) + projectsList.mockResolvedValue([]) + listHostSetups.mockResolvedValue([]) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expect(badgeColor(store)).toBe(REMOTE_NEUTRAL) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index ce1f6c13f13..f2275a8b96e 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -638,6 +638,20 @@ function projectWithCurrentSourceRepoIds( : { ...project, sourceRepoIds } } +function getLocalHostRepoBadgeColor( + project: Project, + reposById: ReadonlyMap +): string | null { + for (const repoId of project.sourceRepoIds) { + for (const repo of reposById.get(repoId) ?? []) { + if (getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID) { + return repo.badgeColor + } + } + } + return null +} + function mergePreviousProjectMetadata( previous: Project, current: Project, @@ -645,6 +659,12 @@ function mergePreviousProjectMetadata( hostId: string ): Project { const project = mergeProjectCompatibilityProject(previous, current) + const sourceRepoIds = getMergedSourceRepoIdsForHostRefresh(previous, current, reposById, hostId) + const localBadgeColor = getLocalHostRepoBadgeColor({ ...project, sourceRepoIds }, reposById) + if (localBadgeColor !== null) { + // Why: badge color is per-host repo metadata; a remote host sharing the project must not repaint the color the user chose locally. + project.badgeColor = localBadgeColor + } if (hostId === LOCAL_EXECUTION_HOST_ID) { // Why: localWindowsRuntimePreference belongs to the local host; a local refresh that omits it is authoritative and clears stale renderer state. if ('localWindowsRuntimePreference' in current) { @@ -663,7 +683,7 @@ function mergePreviousProjectMetadata( return { ...project, // Why: fetched project metadata can lag repo.list; track ownership to the reconciled repos so removed-host repos don't linger. - sourceRepoIds: getMergedSourceRepoIdsForHostRefresh(previous, current, reposById, hostId) + sourceRepoIds } } diff --git a/src/renderer/src/store/slices/ssh-credential-request-retention.test.ts b/src/renderer/src/store/slices/ssh-credential-request-retention.test.ts new file mode 100644 index 00000000000..91a60bb146e --- /dev/null +++ b/src/renderer/src/store/slices/ssh-credential-request-retention.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { getUtf8ByteLength } from '../../../../shared/memory-safety/utf8-byte-limits' +import { + retainSshCredentialRequest, + type SshCredentialRequest, + type SshCredentialRequestRetentionBounds +} from './ssh-credential-request-retention' + +const TEST_BOUNDS: SshCredentialRequestRetentionBounds = { + maxRequests: 2, + maxRequestIdBytes: 8, + maxTargetIdBytes: 8, + maxDetailBytes: 8 +} + +function request(requestId: string, detail = 'prompt'): SshCredentialRequest { + return { + requestId, + targetId: 'target', + kind: 'password', + detail + } +} + +describe('SSH credential request retention', () => { + it('preserves ordinary requests in FIFO order', () => { + const first = retainSshCredentialRequest([], request('first'), TEST_BOUNDS) + const second = retainSshCredentialRequest(first.queue, request('second'), TEST_BOUNDS) + + expect(second.queue.map((entry) => entry.requestId)).toEqual(['first', 'second']) + expect(second.notice).toBeUndefined() + }) + + it('deduplicates repeated request IDs without replacing their FIFO position', () => { + const original = [request('same', 'original')] + const result = retainSshCredentialRequest(original, request('same', 'replacement'), TEST_BOUNDS) + + expect(result.queue).toBe(original) + expect(result.notice).toEqual({ + action: 'dropped', + reason: 'duplicate-request-id', + limit: TEST_BOUNDS.maxRequests + }) + }) + + it('drops new requests after the simultaneous prompt cap', () => { + const original = [request('first'), request('second')] + const result = retainSshCredentialRequest(original, request('third'), TEST_BOUNDS) + + expect(result.queue).toBe(original) + expect(result.notice).toEqual({ + action: 'dropped', + reason: 'queue-full', + limit: TEST_BOUNDS.maxRequests + }) + }) + + it.each([ + ['requestId', 'oversized-request-id'], + ['targetId', 'oversized-target-id'] + ] as const)('drops requests with an oversized %s', (field, reason) => { + const oversized = { ...request('id'), [field]: '😀😀😀' } + const result = retainSshCredentialRequest([], oversized, TEST_BOUNDS) + + expect(result.queue).toEqual([]) + expect(result.notice).toMatchObject({ action: 'dropped', reason }) + }) + + it('truncates display detail on a UTF-8 code point boundary', () => { + const result = retainSshCredentialRequest([], request('id', 'a😀b😀c'), TEST_BOUNDS) + + expect(result.queue[0]?.detail).toBe('a😀b') + expect(getUtf8ByteLength(result.queue[0]?.detail ?? '')).toBeLessThanOrEqual( + TEST_BOUNDS.maxDetailBytes + ) + expect(result.notice).toEqual({ + action: 'truncated', + field: 'detail', + limit: TEST_BOUNDS.maxDetailBytes + }) + }) +}) diff --git a/src/renderer/src/store/slices/ssh-credential-request-retention.ts b/src/renderer/src/store/slices/ssh-credential-request-retention.ts new file mode 100644 index 00000000000..a5bbd5a6670 --- /dev/null +++ b/src/renderer/src/store/slices/ssh-credential-request-retention.ts @@ -0,0 +1,119 @@ +import { clampUtf8TextPrefix, measureUtf8ByteLength } from '../../../../shared/memory-safety/utf8-byte-limits' +import { + SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES, + SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES +} from '../../../../shared/ssh-retained-payload-admission' + +export type SshCredentialRequest = { + requestId: string + targetId: string + kind: 'passphrase' | 'password' + detail: string +} + +export type SshCredentialRequestRetentionBounds = { + maxRequests: number + maxRequestIdBytes: number + maxTargetIdBytes: number + maxDetailBytes: number +} + +export const DEFAULT_SSH_CREDENTIAL_REQUEST_RETENTION_BOUNDS: SshCredentialRequestRetentionBounds = + { + maxRequests: 64, + maxRequestIdBytes: 1024, + maxTargetIdBytes: SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES, + maxDetailBytes: SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES + } + +export type SshCredentialRequestRetentionNotice = + | { + action: 'dropped' + reason: 'duplicate-request-id' | 'queue-full' | 'oversized-request-id' | 'oversized-target-id' + limit: number + } + | { + action: 'truncated' + field: 'detail' + limit: number + } + +export type SshCredentialRequestRetentionResult = { + queue: SshCredentialRequest[] + notice?: SshCredentialRequestRetentionNotice +} + +export function retainSshCredentialRequest( + queue: SshCredentialRequest[], + request: SshCredentialRequest, + bounds: SshCredentialRequestRetentionBounds = DEFAULT_SSH_CREDENTIAL_REQUEST_RETENTION_BOUNDS +): SshCredentialRequestRetentionResult { + if (queue.some((entry) => entry.requestId === request.requestId)) { + return { + queue, + notice: { + action: 'dropped', + reason: 'duplicate-request-id', + limit: bounds.maxRequests + } + } + } + if ( + measureUtf8ByteLength(request.requestId, { + stopAfterBytes: bounds.maxRequestIdBytes + }).exceededLimit + ) { + return { + queue, + notice: { + action: 'dropped', + reason: 'oversized-request-id', + limit: bounds.maxRequestIdBytes + } + } + } + if ( + measureUtf8ByteLength(request.targetId, { + stopAfterBytes: bounds.maxTargetIdBytes + }).exceededLimit + ) { + return { + queue, + notice: { + action: 'dropped', + reason: 'oversized-target-id', + limit: bounds.maxTargetIdBytes + } + } + } + if (queue.length >= bounds.maxRequests) { + return { + queue, + notice: { + action: 'dropped', + reason: 'queue-full', + limit: bounds.maxRequests + } + } + } + + const boundedDetail = clampUtf8TextPrefix(request.detail, bounds.maxDetailBytes) + return { + queue: [ + ...queue, + { + ...request, + detail: boundedDetail + } + ], + ...(boundedDetail !== request.detail + ? { + notice: { + action: 'truncated' as const, + field: 'detail' as const, + limit: bounds.maxDetailBytes + } + } + : {}) + } +} diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 1fde52a31e2..4e857129adf 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -71,6 +71,7 @@ import { disposeParkedTerminalWatchersForPtyIds, retireParkedTerminalTab } from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { forgetRetiredTerminalPaneRecovery } from '@/components/terminal-pane/terminal-pane-recovery-retirement' import { clearCommittedPtyShutdownSettlements, hasCommittedPtyShutdownSettlement, @@ -79,9 +80,11 @@ import { settleDeferredPtyShutdownExits } from '@/components/terminal-pane/pty-shutdown-exit-deferral' import { + collectTerminalLayoutLeafIds, normalizeTerminalLayoutSnapshot, resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { releaseTerminalScrollIntentKeys } from '@/lib/pane-manager/terminal-scroll-intent' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' @@ -1161,10 +1164,14 @@ export const createTerminalSlice: StateCreator opts?.precomputedRetirementPlan?.tabId === tabId ? opts.precomputedRetirementPlan : buildTerminalTabRetirementPlan(get(), tabId) + const retiringScrollIntentLeafIds = collectTerminalLayoutLeafIds( + get().terminalLayoutsByTabId[tabId] + ) let closingWorktreeId: string | null = null // Why: a parked tab has no mounted TerminalPane cleanup, so revoke its observer/candidate state before provider exit races. retireParkedTerminalTab(tabId) + forgetRetiredTerminalPaneRecovery(tabId) if (retiresSession) { const fallbackWorktreeRoute = retirementPlan.worktreeId ? resolveTerminalWorktreeRoute(get(), retirementPlan.worktreeId) @@ -1405,6 +1412,7 @@ export const createTerminalSlice: StateCreator : {}) } }) + releaseTerminalScrollIntentKeys(retiringScrollIntentLeafIds) // Why: closing a tab sweeps live and retained agent-status for it; use dropAgentStatusByTabPrefix so retention suppressors block a same-frame live→gone re-snapshot. // Why: Pi can leave a completed row keyed under an already-missing tab id; pass the worktree to sweep that orphan while preserving active pre-render child rows. get().dropAgentStatusByTabPrefix( diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 66c9a1dbba3..4c0cfd08b29 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -3395,3 +3395,33 @@ describe('openDiffNotesSendMenuForActiveWorktree', () => { expect(store.getState().diffNotesSendMenuOpenRequest).toBeNull() }) }) + +describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => { + it('restores the armed notice from persisted UI', () => { + const store = createUIStore() + + expect(store.getState().osc52ClipboardDefaultOnNoticePending).toBe(false) + store + .getState() + .hydratePersistedUI(makePersistedUI({ osc52ClipboardDefaultOnNoticePending: true })) + + expect(store.getState().osc52ClipboardDefaultOnNoticePending).toBe(true) + }) + + it('stops the toast this session even when the persist fails', () => { + // Why local-first: the flag is the only thing keeping the toast off screen, and a + // rejected ui.set must not leave it re-firing on every render of this session. Losing + // the persist just re-arms the notice next launch, which is the safe direction. + const setUI = vi.fn(() => Promise.reject(new Error('runtime offline'))) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + store + .getState() + .hydratePersistedUI(makePersistedUI({ osc52ClipboardDefaultOnNoticePending: true })) + + store.getState().clearOsc52ClipboardDefaultOnNotice() + + expect(store.getState().osc52ClipboardDefaultOnNoticePending).toBe(false) + expect(setUI).toHaveBeenCalledWith({ osc52ClipboardDefaultOnNoticePending: false }) + }) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index f85e90e2c41..47def908ed7 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -966,6 +966,9 @@ export type UISlice = { setUpdateCardCollapsed: (collapsed: boolean) => void updateReassuranceSeen: boolean markUpdateReassuranceSeen: () => void + /** True on the launch where the OSC 52 default-on migration overrode a persisted `false`. */ + osc52ClipboardDefaultOnNoticePending: boolean + clearOsc52ClipboardDefaultOnNotice: () => void isFullScreen: boolean setIsFullScreen: (v: boolean) => void /** URL opened when a new browser tab is created. Null = blank tab (default). */ @@ -2464,6 +2467,7 @@ export const createUISlice: StateCreator = (set, get) })(), dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, updateReassuranceSeen: ui.updateReassuranceSeen ?? false, + osc52ClipboardDefaultOnNoticePending: ui.osc52ClipboardDefaultOnNoticePending === true, browserDefaultUrl: ui.browserDefaultUrl ?? null, browserDefaultSearchEngine: ui.browserDefaultSearchEngine ?? null, browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(ui.browserDefaultZoomLevel), @@ -2578,6 +2582,13 @@ export const createUISlice: StateCreator = (set, get) void window.api.ui.set({ updateReassuranceSeen: true }).catch(console.error) set({ updateReassuranceSeen: true }) }, + osc52ClipboardDefaultOnNoticePending: false, + clearOsc52ClipboardDefaultOnNotice: () => { + // Why clear locally first: a failed persist must not re-toast this session. It will + // re-arm on the next launch, which is the safe direction for a one-shot notice. + set({ osc52ClipboardDefaultOnNoticePending: false }) + void window.api.ui.set({ osc52ClipboardDefaultOnNoticePending: false }).catch(console.error) + }, isFullScreen: false, setIsFullScreen: (v) => set({ isFullScreen: v }), browserDefaultUrl: null, diff --git a/src/renderer/src/web/web-e2ee.test.ts b/src/renderer/src/web/web-e2ee.test.ts new file mode 100644 index 00000000000..7313e053652 --- /dev/null +++ b/src/renderer/src/web/web-e2ee.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS } from '../../../shared/e2ee-crypto' +import { decrypt, publicKeyFromBase64, WEB_E2EE_PUBLIC_KEY_MAX_BASE64_CHARACTERS } from './web-e2ee' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('web E2EE decode admission', () => { + it('rejects an oversized public key before base64 decoding', () => { + const atob = vi.fn(() => '') + vi.stubGlobal('window', { atob }) + + expect(() => + publicKeyFromBase64('A'.repeat(WEB_E2EE_PUBLIC_KEY_MAX_BASE64_CHARACTERS + 1)) + ).toThrow('encoded value is too large') + expect(atob).not.toHaveBeenCalled() + }) + + it('admits encrypted text at the boundary and rejects one extra character before decoding', () => { + const atob = vi.fn(() => '') + vi.stubGlobal('window', { atob }) + const sharedKey = new Uint8Array(32) + + expect(decrypt('A'.repeat(MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS), sharedKey)).toBeNull() + expect(atob).toHaveBeenCalledOnce() + atob.mockClear() + + expect(decrypt('A'.repeat(MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS + 1), sharedKey)).toBeNull() + expect(atob).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/web/web-e2ee.ts b/src/renderer/src/web/web-e2ee.ts index 933a745ee17..86f1c697996 100644 --- a/src/renderer/src/web/web-e2ee.ts +++ b/src/renderer/src/web/web-e2ee.ts @@ -1,4 +1,7 @@ import nacl from 'tweetnacl' +import { MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS } from '../../../shared/e2ee-crypto' + +export const WEB_E2EE_PUBLIC_KEY_MAX_BASE64_CHARACTERS = 44 if (globalThis.crypto?.getRandomValues) { nacl.setPRNG((bytes, count) => { @@ -15,6 +18,9 @@ export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Ar } export function publicKeyFromBase64(b64: string): Uint8Array { + if (b64.length > WEB_E2EE_PUBLIC_KEY_MAX_BASE64_CHARACTERS) { + throw new Error('Invalid public key: encoded value is too large') + } const key = base64ToBytes(b64) if (key.length !== 32) { throw new Error(`Invalid public key: expected 32 bytes, got ${key.length}`) @@ -31,6 +37,9 @@ export function encrypt(plaintext: string, sharedKey: Uint8Array): string { } export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + if (encrypted.length > MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS) { + return null + } const plaintext = decryptBytes(base64ToBytes(encrypted), sharedKey) return plaintext ? new TextDecoder().decode(plaintext) : null } diff --git a/src/renderer/src/web/web-local-storage-json.test.ts b/src/renderer/src/web/web-local-storage-json.test.ts new file mode 100644 index 00000000000..ef74e96643b --- /dev/null +++ b/src/renderer/src/web/web-local-storage-json.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import { + parseWebLocalStorageJson, + stringifyWebLocalStorageJson, + WEB_LOCAL_STORAGE_JSON_LIMITS, + type WebLocalStorageJsonLimits +} from './web-local-storage-json' + +describe('browser-local JSON memory admission', () => { + it('preserves ordinary JSON serialization and parsing', () => { + const value = { + escaped: '\u0000\n', + nested: [1, true, null, { emoji: '🐋' }], + omitted: undefined + } + const serialized = JSON.stringify(value) + + expect(stringifyWebLocalStorageJson(value)).toBe(serialized) + expect(parseWebLocalStorageJson(serialized)).toEqual(JSON.parse(serialized)) + }) + + it('accepts exact UTF-8 bytes and rejects the next byte', () => { + const limits = createLimits({ maxBytes: 6 }) + + expect(parseWebLocalStorageJson('"🐋"', limits)).toBe('🐋') + expect(stringifyWebLocalStorageJson('🐋', limits)).toBe('"🐋"') + expect(() => parseWebLocalStorageJson('"🐋x"', limits)).toThrow( + 'Browser-local JSON exceeds 6 bytes' + ) + expect(() => stringifyWebLocalStorageJson('🐋x', limits)).toThrow( + 'Browser-local JSON exceeds 6 bytes' + ) + }) + + it('admits exact structure and depth while rejecting the next token or level', () => { + const structuralLimits = createLimits({ structuralTokens: 4 }) + const depthLimits = createLimits({ nestingDepth: 3 }) + + expect(parseWebLocalStorageJson('[0,0,0]', structuralLimits)).toEqual([0, 0, 0]) + expect(stringifyWebLocalStorageJson([0, 0, 0], structuralLimits)).toBe('[0,0,0]') + expect(() => parseWebLocalStorageJson('[0,0,0,0]', structuralLimits)).toThrow( + 'JSON structure exceeds 4 tokens' + ) + expect(() => stringifyWebLocalStorageJson([0, 0, 0, 0], structuralLimits)).toThrow( + 'JSON structure exceeds 4 tokens' + ) + expect(parseWebLocalStorageJson('[[[]]]', depthLimits)).toEqual([[[]]]) + expect(() => parseWebLocalStorageJson('[[[[]]]]', depthLimits)).toThrow( + 'JSON nesting exceeds 3 levels' + ) + }) + + it('rejects amplified input before invoking JSON.parse', () => { + const parse = vi.spyOn(JSON, 'parse') + + expect(() => + parseWebLocalStorageJson('[0,0,0,0]', createLimits({ structuralTokens: 4 })) + ).toThrow('JSON structure exceeds 4 tokens') + expect(parse).not.toHaveBeenCalled() + }) + + it('stops output traversal once the byte limit is exceeded', () => { + const readAfter = vi.fn(() => 'late') + const value = { + payload: 'x'.repeat(100), + get after() { + return readAfter() + } + } + + expect(() => stringifyWebLocalStorageJson(value, createLimits({ maxBytes: 32 }))).toThrow( + 'Browser-local JSON exceeds 32 bytes' + ) + expect(readAfter).not.toHaveBeenCalled() + }) + + it('keeps a finite default persistence envelope', () => { + expect(WEB_LOCAL_STORAGE_JSON_LIMITS).toEqual({ + maxBytes: 8 * 1024 * 1024, + structuralTokens: 1_000_000, + nestingDepth: 128 + }) + }) +}) + +function createLimits(overrides: Partial): WebLocalStorageJsonLimits { + return { + maxBytes: 1_000, + structuralTokens: 100, + nestingDepth: 10, + ...overrides + } +} diff --git a/src/renderer/src/web/web-local-storage-json.ts b/src/renderer/src/web/web-local-storage-json.ts new file mode 100644 index 00000000000..e07dc80d7f8 --- /dev/null +++ b/src/renderer/src/web/web-local-storage-json.ts @@ -0,0 +1,64 @@ +import { + assertJsonTextStructureWithinLimits, + type JsonTextStructureLimits +} from '../../../shared/memory-safety/json-text-structure-limit' +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' +import { + stringifyWebRuntimeOutboundJson, + WebRuntimeOutboundJsonLimitError +} from './web-runtime-outbound-json' + +export type WebLocalStorageJsonLimits = JsonTextStructureLimits & { + maxBytes: number +} + +export const WEB_LOCAL_STORAGE_JSON_LIMITS: WebLocalStorageJsonLimits = { + maxBytes: 8 * 1024 * 1024, + structuralTokens: 1_000_000, + nestingDepth: 128 +} + +export class WebLocalStorageJsonByteCapacityError extends Error { + constructor(readonly maxBytes: number) { + super(`Browser-local JSON exceeds ${maxBytes} bytes`) + this.name = 'WebLocalStorageJsonByteCapacityError' + } +} + +export function parseWebLocalStorageJson( + content: string, + limits: WebLocalStorageJsonLimits = WEB_LOCAL_STORAGE_JSON_LIMITS +): T { + assertWebLocalStorageJsonBytes(content, limits.maxBytes) + assertJsonTextStructureWithinLimits(content, limits) + return JSON.parse(content) as T +} + +export function stringifyWebLocalStorageJson( + value: unknown, + limits: WebLocalStorageJsonLimits = WEB_LOCAL_STORAGE_JSON_LIMITS +): string { + let serialized: string | undefined + try { + serialized = stringifyWebRuntimeOutboundJson(value, limits.maxBytes).serialized + } catch (error) { + if (error instanceof WebRuntimeOutboundJsonLimitError) { + throw new WebLocalStorageJsonByteCapacityError(limits.maxBytes) + } + throw error + } + if (serialized === undefined) { + throw new TypeError('Browser-local JSON value is not serializable') + } + assertJsonTextStructureWithinLimits(serialized, limits) + return serialized +} + +function assertWebLocalStorageJsonBytes(content: string, maxBytes: number): void { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new RangeError('Browser-local JSON byte limit must be a positive safe integer') + } + if (measureUtf8ByteLength(content, { stopAfterBytes: maxBytes }).exceededLimit) { + throw new WebLocalStorageJsonByteCapacityError(maxBytes) + } +} diff --git a/src/renderer/src/web/web-pairing.test.ts b/src/renderer/src/web/web-pairing.test.ts index 6840527d227..f37485d7f22 100644 --- a/src/renderer/src/web/web-pairing.test.ts +++ b/src/renderer/src/web/web-pairing.test.ts @@ -1,4 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { + PAIRING_CODE_MAX_CHARACTERS, + PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, + PAIRING_ENDPOINT_MAX_CHARACTERS, + PAIRING_INPUT_MAX_CHARACTERS, + PAIRING_PUBLIC_KEY_MAX_CHARACTERS +} from '../../../shared/mobile-relay-pairing-offer' import { decideWebPairingStartup, parseWebPairingInput, type WebPairingOffer } from './web-pairing' describe('web pairing input', () => { @@ -51,6 +58,23 @@ describe('web pairing input', () => { expect(parseWebPairingInput(`orca://pair-extra?code=${encodeOffer()}`)).toBeNull() }) + it('rejects oversized input before base64 decoding', () => { + const atob = vi.spyOn(globalThis, 'atob') + + expect(parseWebPairingInput('A'.repeat(PAIRING_INPUT_MAX_CHARACTERS + 1))).toBeNull() + expect(parseWebPairingInput('A'.repeat(PAIRING_CODE_MAX_CHARACTERS + 1))).toBeNull() + expect(atob).not.toHaveBeenCalled() + }) + + it.each([ + ['endpoint', PAIRING_ENDPOINT_MAX_CHARACTERS], + ['deviceToken', PAIRING_DEVICE_TOKEN_MAX_CHARACTERS], + ['publicKeyB64', PAIRING_PUBLIC_KEY_MAX_CHARACTERS] + ] as const)('accepts %s at its limit and rejects one extra character', (field, limit) => { + expect(parseWebPairingInput(encodeOffer({ [field]: 'x'.repeat(limit) }))).not.toBeNull() + expect(parseWebPairingInput(encodeOffer({ [field]: 'x'.repeat(limit + 1) }))).toBeNull() + }) + it('auto-saves scoped runtime offers during web startup', () => { const input = `orca://pair?code=${encodeOffer({ scope: 'runtime' })}` expect( diff --git a/src/renderer/src/web/web-pairing.ts b/src/renderer/src/web/web-pairing.ts index f29465cc803..165fe5ac8f4 100644 --- a/src/renderer/src/web/web-pairing.ts +++ b/src/renderer/src/web/web-pairing.ts @@ -1,4 +1,11 @@ import type { DeviceScope } from '../../../shared/runtime-types' +import { + PAIRING_CODE_MAX_CHARACTERS, + PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, + PAIRING_ENDPOINT_MAX_CHARACTERS, + PAIRING_INPUT_MAX_CHARACTERS, + PAIRING_PUBLIC_KEY_MAX_CHARACTERS +} from '../../../shared/mobile-relay-pairing-offer' const PAIRING_OFFER_VERSION = 2 @@ -16,6 +23,9 @@ export type WebPairingStartupDecision = | { kind: 'use-stored-environment' } export function parseWebPairingInput(input: string): WebPairingOffer | null { + if (input.length > PAIRING_INPUT_MAX_CHARACTERS) { + return null + } const trimmed = input.trim() if (!trimmed) { return null @@ -33,6 +43,9 @@ export function parseWebPairingInput(input: string): WebPairingOffer | null { } export function readPairingInputFromLocation(location: Location): string | null { + if (location.search.length + location.hash.length > PAIRING_INPUT_MAX_CHARACTERS) { + return null + } const search = new URLSearchParams(location.search) for (const key of ['pairing', 'pair', 'code', 'token']) { const value = search.get(key) @@ -85,16 +98,26 @@ export function clearPairingInputFromAddressBar(): void { } function decodePairingPayload(base64url: string): WebPairingOffer | null { + if ( + base64url.length === 0 || + base64url.length > PAIRING_CODE_MAX_CHARACTERS || + !/^[A-Za-z0-9+/_-]+={0,2}$/.test(base64url) + ) { + return null + } const json = new TextDecoder().decode(base64UrlToBytes(base64url)) const parsed = JSON.parse(json) as Partial if ( parsed.v !== PAIRING_OFFER_VERSION || typeof parsed.endpoint !== 'string' || parsed.endpoint.length === 0 || + parsed.endpoint.length > PAIRING_ENDPOINT_MAX_CHARACTERS || typeof parsed.deviceToken !== 'string' || parsed.deviceToken.length === 0 || + parsed.deviceToken.length > PAIRING_DEVICE_TOKEN_MAX_CHARACTERS || typeof parsed.publicKeyB64 !== 'string' || - parsed.publicKeyB64.length === 0 + parsed.publicKeyB64.length === 0 || + parsed.publicKeyB64.length > PAIRING_PUBLIC_KEY_MAX_CHARACTERS ) { return null } diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 60f2f636f97..f5366ee13a0 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -439,6 +439,21 @@ describe('web keybindings preload API', () => { unsubscribe() }) + + it('caps keybinding listeners and releases their storage handlers on reinstall', async () => { + const { api, window } = await installApi('Linux') + const { installWebPreloadApi, WEB_PRELOAD_MAX_KEYBINDING_LISTENERS } = + await import('./web-preload-api') + for (let index = 0; index < WEB_PRELOAD_MAX_KEYBINDING_LISTENERS; index += 1) { + api.keybindings.onChanged(vi.fn()) + } + + expect(() => api.keybindings.onChanged(vi.fn())).toThrow('listener capacity reached') + installWebPreloadApi() + + expect(window.removeEventListener).toHaveBeenCalledTimes(WEB_PRELOAD_MAX_KEYBINDING_LISTENERS) + expect(() => window.api.keybindings.onChanged(vi.fn())).not.toThrow() + }) }) describe('web settings preload API', () => { @@ -506,6 +521,96 @@ describe('web settings preload API', () => { expect(settings.terminalCursorStyleDefaultedToBlock).toBe(true) }) + it('migrates OSC 52 clipboard writes on for stored web settings once', async () => { + // Why: the web store is a second, independent settings store — the constants-level + // default flip only reaches profiles that never persisted the old `false` (#10567). + const globals = installBrowserGlobals('Linux') + globals.storage.setItem( + 'orca.web.settings.v1', + JSON.stringify({ terminalAllowOsc52Clipboard: false }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.get() + const stored = JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}') as { + terminalAllowOsc52Clipboard?: boolean + terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean + } + + expect(settings.terminalAllowOsc52Clipboard).toBe(true) + expect(settings.terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + expect(stored.terminalAllowOsc52Clipboard).toBe(true) + expect(stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + + it('arms the OSC 52 notice in the web UI store when the flip overrides a persisted off', async () => { + const globals = installBrowserGlobals('Linux') + globals.storage.setItem( + 'orca.web.settings.v1', + JSON.stringify({ terminalAllowOsc52Clipboard: false }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.settings.get() + const storedUi = JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}') as { + osc52ClipboardDefaultOnNoticePending?: boolean + } + + expect(storedUi.osc52ClipboardDefaultOnNoticePending).toBe(true) + }) + + it('does not arm the OSC 52 notice for a web profile with no persisted value', async () => { + const globals = installBrowserGlobals('Linux') + globals.storage.setItem('orca.web.settings.v1', JSON.stringify({ terminalFontSize: 15 })) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.settings.get() + const storedUi = JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}') as { + osc52ClipboardDefaultOnNoticePending?: boolean + } + + expect(storedUi.osc52ClipboardDefaultOnNoticePending).not.toBe(true) + }) + + it('arms the OSC 52 notice when ui.get is the read that runs the migration', async () => { + // Why seed after install: readLocalWebUIState must run the settings migration before it + // snapshots the UI blob, and only a ui.get that is itself the first settings read can + // show that. Reading first returns a pre-arm state every caller then writes back — and + // the stamp means nothing can raise the arm again. Another tab populating localStorage + // after this one loaded is the shape that reaches it. + const globals = installBrowserGlobals('Linux') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + globals.storage.setItem( + 'orca.web.settings.v1', + JSON.stringify({ terminalAllowOsc52Clipboard: false }) + ) + + const ui = await globals.window.api.ui.get() + + expect(ui.osc52ClipboardDefaultOnNoticePending).toBe(true) + }) + + it('preserves OSC 52 clipboard web opt-outs after migration', async () => { + const globals = installBrowserGlobals('Linux') + globals.storage.setItem( + 'orca.web.settings.v1', + JSON.stringify({ + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.get() + expect(settings.terminalAllowOsc52Clipboard).toBe(false) + expect(settings.terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + it('preserves first-work branch auto-rename web opt-outs after migration', async () => { const globals = installBrowserGlobals('Linux') globals.storage.setItem( @@ -1850,6 +1955,70 @@ describe('web UI preload API', () => { expect(stored.contextualToursSeenIds).toEqual(['tasks', 'browser']) }) + it('keeps the local OSC 52 notice armed when ui.get returns an unmigrated host', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: true, + // Why false: the host store always projects this key, so a plain spread + // would overwrite the arm the web client's own settings migration raised. + result: { ui: { osc52ClipboardDefaultOnNoticePending: false } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem( + 'orca.web.ui.v1', + JSON.stringify({ osc52ClipboardDefaultOnNoticePending: true }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const ui = await globals.window.api.ui.get() + expect(ui.osc52ClipboardDefaultOnNoticePending).toBe(true) + + await globals.window.api.ui.set({ osc52ClipboardDefaultOnNoticePending: false }) + const cleared = await globals.window.api.ui.get() + expect(cleared.osc52ClipboardDefaultOnNoticePending).toBe(false) + }) + + it('keeps the local OSC 52 notice armed when recordFeatureInteraction returns an unmigrated host', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: true, + result: { ui: { osc52ClipboardDefaultOnNoticePending: false } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem( + 'orca.web.ui.v1', + JSON.stringify({ osc52ClipboardDefaultOnNoticePending: true }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const ui = await globals.window.api.ui.recordFeatureInteraction('tasks') + expect(ui.osc52ClipboardDefaultOnNoticePending).toBe(true) + }) + it('does not keep a local shadow copy of main-owned feature telemetry markers', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a1ed5406ced..76ba5d50264 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -77,6 +77,10 @@ import { } from '../../../shared/tui-agent-launch-defaults' import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../../../shared/osc52-clipboard-settings' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' @@ -108,7 +112,9 @@ import { } from './web-runtime-environment' import { parseWebPairingInput } from './web-pairing' import { WebRuntimeClient } from './web-runtime-client' +import { parseWebLocalStorageJson, stringifyWebLocalStorageJson } from './web-local-storage-json' import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue' +import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import { assertClipboardTextWriteWithinLimitWithYield, assertClipboardTextWithinLimitWithYield, @@ -123,6 +129,7 @@ import { assertClipboardImageDimensionsWithinLimit } from '../../../shared/clipboard-image' import { sanitizeWebRuntimeWorkspaceSession } from './web-workspace-session' +import { WebPreloadRequestOwners } from './web-preload-request-owners' import { normalizeFeatureInteractions, type FeatureInteractionId, @@ -145,12 +152,14 @@ const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1' const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1' // Why: paired clients need parity for large dev sessions; the runtime default stays capped for lower-level RPC callers. const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 +export const WEB_RUNTIME_REPO_DISCOVERY_CONCURRENCY = 8 const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS export const MAX_CLIPBOARD_IMAGE_SOURCE_BYTES = CLIPBOARD_IMAGE_MAX_SOURCE_BYTES export const MAX_CLIPBOARD_IMAGE_PIXELS = CLIPBOARD_IMAGE_MAX_PIXELS export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000 +export const WEB_PRELOAD_MAX_KEYBINDING_LISTENERS = 64 let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment() let activeClient: WebRuntimeClient | null = null @@ -474,9 +483,17 @@ export const GITLAB_WEB_RPC_METHODS = { } as const satisfies Record const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] -const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>() +type WebKeybindingSubscription = { + target: Window + onStorage: (event: StorageEvent) => void +} +const webKeybindingSubscriptions = new Map< + (snapshot: KeybindingFileSnapshot) => void, + WebKeybindingSubscription +>() export function installWebPreloadApi(): void { + disposeWebPreloadOwnedState() activeEnvironment = readStoredWebRuntimeEnvironment() const webWindow = window as unknown as { __ORCA_WEB_CLIENT__?: boolean } webWindow.__ORCA_WEB_CLIENT__ = true @@ -673,7 +690,9 @@ function createWebPreloadApi(): Partial { Promise.resolve({ ok: false, error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') - }) + }), + // Why: no Electron process on web; the caller falls back to performance.memory. + readHeapStatistics: () => null }, diagnostics: { getStatus: () => @@ -1107,7 +1126,7 @@ function writeWebKeybindingAction( } function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void { - for (const listener of webKeybindingListeners) { + for (const listener of webKeybindingSubscriptions.keys()) { listener(snapshot) } } @@ -1125,21 +1144,44 @@ function createWebKeybindingsApi(): WebKeybindingsApi { openFile: () => Promise.resolve(getWebKeybindingSnapshot()), revealFile: () => Promise.resolve(getWebKeybindingSnapshot()), onChanged: (callback) => { - webKeybindingListeners.add(callback) + const existing = webKeybindingSubscriptions.get(callback) + if (existing) { + return () => releaseWebKeybindingSubscription(callback, existing) + } + if (webKeybindingSubscriptions.size >= WEB_PRELOAD_MAX_KEYBINDING_LISTENERS) { + throw new Error('Web keybinding listener capacity reached; remove a listener and retry.') + } + const target = window const onStorage = (event: StorageEvent): void => { if (event.key === KEYBINDINGS_STORAGE_KEY) { callback(getWebKeybindingSnapshot()) } } - window.addEventListener('storage', onStorage) - return () => { - webKeybindingListeners.delete(callback) - window.removeEventListener('storage', onStorage) - } + const subscription = { target, onStorage } + webKeybindingSubscriptions.set(callback, subscription) + target.addEventListener('storage', onStorage) + return () => releaseWebKeybindingSubscription(callback, subscription) } } } +function releaseWebKeybindingSubscription( + callback: (snapshot: KeybindingFileSnapshot) => void, + expected: WebKeybindingSubscription +): void { + if (webKeybindingSubscriptions.get(callback) !== expected) { + return + } + webKeybindingSubscriptions.delete(callback) + expected.target.removeEventListener('storage', expected.onStorage) +} + +function disposeWebKeybindingSubscriptions(): void { + for (const [callback, subscription] of webKeybindingSubscriptions) { + releaseWebKeybindingSubscription(callback, subscription) + } +} + // Why: web has no IPC for native-chat transcripts, so route readSession/subscribe through runtime RPC (as mobile does). function createNativeChatApi(): NativeChatApi { return { @@ -1769,16 +1811,14 @@ function createFileApi(): NonNullable['fs']> { } // Why: track the in-flight abortable status request per token so cancelStatus can abort it and close its remote context. -const webGitStatusAbortControllers = new Map() +const webGitStatusRequestOwners = new WebPreloadRequestOwners() async function callAbortableRuntimeStatus( requestToken: string, params: unknown ): Promise { const environment = requireActiveEnvironment() - webGitStatusAbortControllers.get(requestToken)?.abort() - const controller = new AbortController() - webGitStatusAbortControllers.set(requestToken, controller) + const controller = webGitStatusRequestOwners.replace(requestToken) try { const response = await callAbortableRuntimeEnvironment( environment.id, @@ -1793,9 +1833,7 @@ async function callAbortableRuntimeStatus( } return response.result as TResult } finally { - if (webGitStatusAbortControllers.get(requestToken) === controller) { - webGitStatusAbortControllers.delete(requestToken) - } + webGitStatusRequestOwners.release(requestToken, controller) } } @@ -1822,7 +1860,7 @@ function createGitApi(): NonNullable['git']> { return callAbortableRuntimeStatus(requestToken, params) }, cancelStatus: async ({ requestToken }) => { - webGitStatusAbortControllers.get(requestToken)?.abort() + webGitStatusRequestOwners.abort(requestToken) }, submoduleStatus: async ({ worktreePath, submodulePath, area }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) @@ -2395,6 +2433,7 @@ function createWebUiApi(): NonNullable['ui']> { const local = readLocalWebUIState() const next = { ...mergeWebUIState(local, result.ui), + osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), featureInteractions: mergeFeatureInteractionState( local.featureInteractions, result.ui.featureInteractions @@ -2444,6 +2483,7 @@ function createWebUiApi(): NonNullable['ui']> { const local = readLocalWebUIState() const next = { ...mergeWebUIState(local, result.ui), + osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), featureInteractions: mergeFeatureInteractionState( local.featureInteractions, result.ui.featureInteractions @@ -3282,12 +3322,18 @@ function getClientForEnvironment(environment: StoredWebRuntimeEnvironment): WebR } function closeActiveRuntimeClients(): void { + webGitStatusRequestOwners.abortAll() activeClient?.close() activeClient = null activeClientEnvironmentId = null invalidateRuntimeWorktreeCaches() } +function disposeWebPreloadOwnedState(): void { + disposeWebKeybindingSubscriptions() + closeActiveRuntimeClients() +} + function disconnectActiveRuntimeEnvironment(): void { closeActiveRuntimeClients() clearStoredWebRuntimeEnvironment() @@ -3338,33 +3384,40 @@ function updateEnvironmentFromResponse( function getStoredSettings(): GlobalSettings { activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() const defaults = getDefaultSettings('~') - const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY) - const stored = readJson>(SETTINGS_STORAGE_KEY, {}) + const storedResult = readJsonWithMetadata>(SETTINGS_STORAGE_KEY, {}) + const stored = storedResult.value const migratedStored = { ...stored, ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), ...normalizeTerminalCursorStyleDefault(stored), + ...normalizeOsc52ClipboardDefaultOn(stored), terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes), uiLanguage: normalizeUiLanguage(stored.uiLanguage) } if ( - rawStoredSettings && + storedResult.parsedPlainObject && (stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork || stored.autoRenameBranchFromWorkDefaultedOn !== migratedStored.autoRenameBranchFromWorkDefaultedOn || stored.terminalCursorStyle !== migratedStored.terminalCursorStyle || stored.terminalCursorStyleDefaultedToBlock !== migratedStored.terminalCursorStyleDefaultedToBlock || + // Kept even though the terminalCustomThemes reference compare below already forces + // this branch for every stored blob: no migration should rely on that accident. + stored.terminalAllowOsc52Clipboard !== migratedStored.terminalAllowOsc52Clipboard || + stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== + migratedStored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers || stored.terminalCustomThemes !== migratedStored.terminalCustomThemes || stored.uiLanguage !== migratedStored.uiLanguage) ) { - try { - const parsed = JSON.parse(rawStoredSettings) as unknown - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - writeJson(SETTINGS_STORAGE_KEY, migratedStored) - } - } catch { - // Keep readJson's invalid-JSON fallback non-destructive. + writeJson(SETTINGS_STORAGE_KEY, migratedStored) + if (osc52ClipboardDefaultOnOverridesPersistedOff(stored)) { + // Why a raw merge, not readLocalWebUIState(): that path calls back into + // getStoredSettings(), and writing through it here would recurse. + writeJson(UI_STORAGE_KEY, { + ...readJson>(UI_STORAGE_KEY, {}), + osc52ClipboardDefaultOnNoticePending: true + }) } } return mergeSettings( @@ -3572,8 +3625,11 @@ function closeWebOnboarding(base: OnboardingState): OnboardingState { function readLocalWebUIState(): PersistedUIState { const defaults = getDefaultUIState() - const stored = readJson>(UI_STORAGE_KEY, {}) + // Why settings first: getStoredSettings() runs the OSC 52 migration, which writes the + // notice arm into UI_STORAGE_KEY. Reading before it would snapshot a pre-arm state that + // every caller then writes back, erasing the arm the stamp can never raise again. const storedSettings = getStoredSettings() + const stored = readJson>(UI_STORAGE_KEY, {}) const base = { ...defaults, // Why: mirror the main-process missing-property seed from legacy card layout mode when runtime ui.get is unavailable. @@ -3657,6 +3713,19 @@ function mergeContextualTourSeenIds( return [...merged] } +/** Why OR rather than let the host win: the web client migrates its own localStorage + * settings, so an arm raised here has no counterpart on the host, and the plain spread + * would drop it — flipping the opt-out in silence, which is what the notice exists to stop. */ +function mergeOsc52ClipboardNoticePending( + current: PersistedUIState, + incoming: PersistedUIState +): boolean { + return ( + current.osc52ClipboardDefaultOnNoticePending === true || + incoming.osc52ClipboardDefaultOnNoticePending === true + ) +} + function mergeSettings( base: GlobalSettings, updates: Partial, @@ -3732,10 +3801,10 @@ async function listAllRuntimeDetectedWorktrees( assertActiveEnvironment(expectedEnvironmentId) const repos = (await callResult<{ repos: Repo[] }>('repo.list')).repos - const detectedLists = await Promise.all( - repos.map((repo) => - callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope) - ) + const detectedLists = await mapWithConcurrency( + repos, + WEB_RUNTIME_REPO_DISCOVERY_CONCURRENCY, + (repo) => callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope) ) const worktrees = detectedLists.flatMap((result) => result.worktrees) assertActiveEnvironment(expectedEnvironmentId) @@ -3934,23 +4003,34 @@ function getBrowserPlatform(): NodeJS.Platform { } function readJson(key: string, fallback: T): T { + return readJsonWithMetadata(key, fallback).value +} + +function readJsonWithMetadata( + key: string, + fallback: T +): { value: T; parsedPlainObject: boolean } { const raw = window.localStorage.getItem(key) if (!raw) { - return cloneJson(fallback) + return { value: cloneJson(fallback), parsedPlainObject: false } } try { - return { ...cloneJson(fallback), ...JSON.parse(raw) } as T + const parsed = parseWebLocalStorageJson(raw) + return { + value: { ...cloneJson(fallback), ...(parsed as object) } as T, + parsedPlainObject: parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + } } catch { - return cloneJson(fallback) + return { value: cloneJson(fallback), parsedPlainObject: false } } } function writeJson(key: string, value: T): void { - window.localStorage.setItem(key, JSON.stringify(value)) + window.localStorage.setItem(key, stringifyWebLocalStorageJson(value)) } function cloneJson(value: T): T { - return JSON.parse(JSON.stringify(value)) as T + return parseWebLocalStorageJson(stringifyWebLocalStorageJson(value)) } function withFallback(target: T, path: string[]): T { diff --git a/src/renderer/src/web/web-preload-request-owners.test.ts b/src/renderer/src/web/web-preload-request-owners.test.ts new file mode 100644 index 00000000000..31be17cfa36 --- /dev/null +++ b/src/renderer/src/web/web-preload-request-owners.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { + WEB_PRELOAD_MAX_ABORTABLE_REQUESTS, + WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES, + WebPreloadRequestOwners +} from './web-preload-request-owners' + +describe('web preload request owners', () => { + it('caps unique request tokens and recovers after release', () => { + const owners = new WebPreloadRequestOwners() + const first = owners.replace('request-0') + for (let index = 1; index < WEB_PRELOAD_MAX_ABORTABLE_REQUESTS; index += 1) { + owners.replace(`request-${index}`) + } + + expect(() => owners.replace('overflow')).toThrow('capacity reached') + owners.release('request-0', first) + expect(() => owners.replace('recovered')).not.toThrow() + }) + + it('replaces same-token ownership without growing or letting stale cleanup win', () => { + const owners = new WebPreloadRequestOwners() + const first = owners.replace('stable') + const replacement = owners.replace('stable') + + expect(first.signal.aborted).toBe(true) + expect(owners.size()).toBe(1) + owners.release('stable', first) + expect(owners.size()).toBe(1) + owners.release('stable', replacement) + expect(owners.size()).toBe(0) + }) + + it('bounds multibyte request tokens before retaining them', () => { + const owners = new WebPreloadRequestOwners() + const exact = 'é'.repeat(WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES / 2) + + expect(() => owners.replace(exact)).not.toThrow() + expect(() => owners.replace(`${exact}x`)).toThrow( + `between 1 and ${WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES} UTF-8 bytes` + ) + }) + + it('aborts and releases every owner on teardown', () => { + const owners = new WebPreloadRequestOwners() + const first = owners.replace('first') + const second = owners.replace('second') + + owners.abortAll() + + expect(first.signal.aborted).toBe(true) + expect(second.signal.aborted).toBe(true) + expect(owners.size()).toBe(0) + }) +}) diff --git a/src/renderer/src/web/web-preload-request-owners.ts b/src/renderer/src/web/web-preload-request-owners.ts new file mode 100644 index 00000000000..e304a3bac22 --- /dev/null +++ b/src/renderer/src/web/web-preload-request-owners.ts @@ -0,0 +1,49 @@ +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' + +export const WEB_PRELOAD_MAX_ABORTABLE_REQUESTS = 256 +export const WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES = 4 * 1024 + +export class WebPreloadRequestOwners { + private readonly controllers = new Map() + + replace(requestToken: string): AbortController { + const measured = measureUtf8ByteLength(requestToken, { + stopAfterBytes: WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES + }) + if (requestToken.length === 0 || measured.exceededLimit) { + throw new Error( + `Web request token must be between 1 and ${WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES} UTF-8 bytes.` + ) + } + const existing = this.controllers.get(requestToken) + if (!existing && this.controllers.size >= WEB_PRELOAD_MAX_ABORTABLE_REQUESTS) { + throw new Error('Web request capacity reached; wait for an active request to finish.') + } + existing?.abort() + const controller = new AbortController() + this.controllers.set(requestToken, controller) + return controller + } + + abort(requestToken: string): void { + this.controllers.get(requestToken)?.abort() + } + + release(requestToken: string, expected: AbortController): void { + if (this.controllers.get(requestToken) === expected) { + this.controllers.delete(requestToken) + } + } + + abortAll(): void { + const controllers = [...this.controllers.values()] + this.controllers.clear() + for (const controller of controllers) { + controller.abort() + } + } + + size(): number { + return this.controllers.size + } +} diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 4a15fcce2ef..8b6ba6fe8fe 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -12,6 +12,18 @@ import { } from '../../../shared/e2ee-crypto' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +type TestSubscriptionCallbacks = { + onResponse: (response: RuntimeRpcResponse) => void +} + +type SubscribePreparedForTest = ( + method: string, + preparedInput: unknown, + releaseRetainedBytes: () => void, + callbacks: TestSubscriptionCallbacks, + options?: unknown +) => Promise<{ unsubscribe: () => void; sendBinary: (bytes: Uint8Array) => void }> + const fakeSockets: FakeWebSocket[] = [] class FakeWebSocket { @@ -91,6 +103,31 @@ describe('WebRuntimeClient', () => { expect(child.close).toHaveBeenCalledWith({ notifySubscriptions: false }) }) + it('closes the socket when handshake JSON exceeds the nesting cap', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const socket = fakeSockets[0]! + const internals = client as unknown as { + state: 'handshaking' + sharedKey: Uint8Array + handleSocketMessage: (rawData: unknown, sourceWs?: WebSocket) => Promise + } + internals.state = 'handshaking' + internals.sharedKey = new Uint8Array(32) + + await internals.handleSocketMessage( + `${'['.repeat(129)}0${']'.repeat(129)}`, + socket as unknown as WebSocket + ) + + expect(socket.close).toHaveBeenCalledOnce() + client.close() + }) + it('does not report locally closed subscriptions as remote closes', () => { const client = new WebRuntimeClient({ v: 2, @@ -258,23 +295,24 @@ describe('WebRuntimeClient', () => { const handle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } const internals = client as unknown as { childClients: Set - subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + subscribePreparedOnCurrentConnection: SubscribePreparedForTest } - const subscribeOnCurrentConnection = vi - .spyOn(internals, 'subscribeOnCurrentConnection') + const subscribePrepared = vi + .spyOn(internals, 'subscribePreparedOnCurrentConnection') .mockResolvedValue(handle) const onResponse = vi.fn() const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) - expect(subscribeOnCurrentConnection).toHaveBeenCalledWith( + expect(subscribePrepared).toHaveBeenCalledWith( 'files.watch', - { worktree: 'wt-1' }, + expect.objectContaining({ paramsByteLength: 19, worktree: 'wt-1' }), + expect.any(Function), expect.objectContaining({ onResponse: expect.any(Function) }), undefined ) expect(internals.childClients.size).toBe(0) - subscribeOnCurrentConnection.mock.calls[0]?.[2].onResponse({ + subscribePrepared.mock.calls[0]?.[3].onResponse({ id: 'watch', ok: true, streaming: true, @@ -297,10 +335,10 @@ describe('WebRuntimeClient', () => { }) const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } const internals = client as unknown as { - subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + subscribePreparedOnCurrentConnection: SubscribePreparedForTest } - const subscribeOnCurrentConnection = vi - .spyOn(internals, 'subscribeOnCurrentConnection') + const subscribePrepared = vi + .spyOn(internals, 'subscribePreparedOnCurrentConnection') .mockResolvedValue(localHandle) const unwatch = vi.spyOn(client, 'call').mockImplementation(() => { expect(localHandle.unsubscribe).not.toHaveBeenCalled() @@ -314,7 +352,7 @@ describe('WebRuntimeClient', () => { const onResponse = vi.fn() const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) - const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] + const wrappedCallbacks = subscribePrepared.mock.calls[0]?.[3] wrappedCallbacks?.onResponse({ id: 'watch', ok: true, @@ -344,10 +382,10 @@ describe('WebRuntimeClient', () => { }) const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } const internals = client as unknown as { - subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + subscribePreparedOnCurrentConnection: SubscribePreparedForTest } - const subscribeOnCurrentConnection = vi - .spyOn(internals, 'subscribeOnCurrentConnection') + const subscribePrepared = vi + .spyOn(internals, 'subscribePreparedOnCurrentConnection') .mockResolvedValue(localHandle) const unwatch = vi .spyOn(client, 'call') @@ -370,7 +408,7 @@ describe('WebRuntimeClient', () => { { worktree: 'wt-1' }, { onResponse: vi.fn() } ) - const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] + const wrappedCallbacks = subscribePrepared.mock.calls[0]?.[3] wrappedCallbacks?.onResponse({ id: 'watch', ok: true, @@ -396,7 +434,7 @@ describe('WebRuntimeClient', () => { await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse: vi.fn() }) expect(unwatch).toHaveBeenCalledTimes(2) expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1) - expect(subscribeOnCurrentConnection).toHaveBeenCalledTimes(2) + expect(subscribePrepared).toHaveBeenCalledTimes(2) } finally { client.close() warn.mockRestore() @@ -415,10 +453,10 @@ describe('WebRuntimeClient', () => { sendBinary: vi.fn() })) const internals = client as unknown as { - subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + subscribePreparedOnCurrentConnection: SubscribePreparedForTest } - const subscribeOnCurrentConnection = vi - .spyOn(internals, 'subscribeOnCurrentConnection') + const subscribePrepared = vi + .spyOn(internals, 'subscribePreparedOnCurrentConnection') .mockResolvedValueOnce(handles[0]) .mockResolvedValueOnce(handles[1]) .mockResolvedValueOnce(handles[2]) @@ -445,7 +483,7 @@ describe('WebRuntimeClient', () => { { onResponse: vi.fn() } ) for (const [index, subscriptionId] of ['watch-a', 'watch-b'].entries()) { - subscribeOnCurrentConnection.mock.calls[index]?.[2].onResponse({ + subscribePrepared.mock.calls[index]?.[3].onResponse({ id: subscriptionId, ok: true, streaming: true, @@ -462,7 +500,7 @@ describe('WebRuntimeClient', () => { expect(unwatch).toHaveBeenCalledTimes(4) expect(handles[0].unsubscribe).toHaveBeenCalledTimes(1) expect(handles[1].unsubscribe).toHaveBeenCalledTimes(1) - expect(subscribeOnCurrentConnection).toHaveBeenCalledTimes(3) + expect(subscribePrepared).toHaveBeenCalledTimes(3) } finally { client.close() warn.mockRestore() @@ -478,10 +516,10 @@ describe('WebRuntimeClient', () => { }) const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } const internals = client as unknown as { - subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + subscribePreparedOnCurrentConnection: SubscribePreparedForTest } - const subscribeOnCurrentConnection = vi - .spyOn(internals, 'subscribeOnCurrentConnection') + const subscribePrepared = vi + .spyOn(internals, 'subscribePreparedOnCurrentConnection') .mockResolvedValue(localHandle) const unwatch = vi.spyOn(client, 'call').mockResolvedValue({ id: 'unwatch', @@ -492,7 +530,7 @@ describe('WebRuntimeClient', () => { const onResponse = vi.fn() const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) - const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] + const wrappedCallbacks = subscribePrepared.mock.calls[0]?.[3] subscription.unsubscribe() expect(unwatch).not.toHaveBeenCalled() @@ -532,10 +570,10 @@ describe('WebRuntimeClient', () => { }) const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } const internals = client as unknown as { - subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + subscribePreparedOnCurrentConnection: SubscribePreparedForTest } - const subscribeOnCurrentConnection = vi - .spyOn(internals, 'subscribeOnCurrentConnection') + const subscribePrepared = vi + .spyOn(internals, 'subscribePreparedOnCurrentConnection') .mockResolvedValue(localHandle) const unwatch = vi.spyOn(client, 'call').mockResolvedValue({ id: 'unwatch', @@ -558,7 +596,7 @@ describe('WebRuntimeClient', () => { await vi.advanceTimersByTimeAsync(300_000) expect(localHandle.unsubscribe).not.toHaveBeenCalled() - const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] + const wrappedCallbacks = subscribePrepared.mock.calls[0]?.[3] wrappedCallbacks?.onResponse({ id: 'watch', ok: true, @@ -693,6 +731,11 @@ describe('WebRuntimeClient', () => { const internals = client as unknown as { waitForConnected: (timeoutMs?: number) => Promise sendEncrypted: (message: unknown) => boolean + sendEncryptedSerialized: (serialized: string) => { + accepted: boolean + queued: boolean + cancel: () => boolean + } subscribeOnCurrentConnection: ( method: string, params: unknown, @@ -701,6 +744,11 @@ describe('WebRuntimeClient', () => { ) => Promise<{ unsubscribe: () => void }> } vi.spyOn(internals, 'waitForConnected').mockResolvedValue(undefined) + vi.spyOn(internals, 'sendEncryptedSerialized').mockReturnValue({ + accepted: true, + queued: false, + cancel: () => false + }) const sent: unknown[] = [] vi.spyOn(internals, 'sendEncrypted').mockImplementation((message) => { sent.push(message) diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 70574350400..1f0c96f1152 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -1,6 +1,13 @@ /* eslint-disable max-lines -- Why: one transport boundary — E2EE WebSocket state machine, JSON-RPC routing, streaming, binary frame forwarding. */ import type { RuntimeRpcResponse, RuntimeRpcSuccess } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' +import { MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS } from '../../../shared/e2ee-crypto' +import { + createWsOutboundBackpressureQueue, + type WsOutboundEnqueueResult, + type WsOutboundBackpressureQueue +} from '../../../shared/ws-outbound-backpressure-queue' import type { WebPairingOffer } from './web-pairing' import { installWindowVisibilityInterval } from '../lib/window-visibility-interval' import { withRemoteRuntimeTailscaleHint } from '../../../shared/remote-runtime-tailscale-hint' @@ -14,6 +21,21 @@ import { publicKeyFromBase64, publicKeyToBase64 } from './web-e2ee' +import { + createWebRuntimeOutboundMemoryBudget, + WEB_RUNTIME_OUTBOUND_MAX_QUEUED_BYTES, + WEB_RUNTIME_OUTBOUND_MAX_QUEUED_FRAMES, + type WebRuntimeOutboundMemoryBudget, + type WebRuntimeOutboundSocketMemory +} from './web-runtime-outbound-memory-budget' +import { + stringifyWebRuntimeOutboundJson, + WebRuntimeOutboundJsonLimitError +} from './web-runtime-outbound-json' +import { + isWebRuntimeJsonStructureCapacityError, + parseWebRuntimeInboundJson +} from './web-runtime-inbound-json' type WebRuntimeConnectionState = | 'disconnected' @@ -23,6 +45,7 @@ type WebRuntimeConnectionState = | 'auth-failed' type PendingRequest = { + cancelQueuedFrame: () => boolean method: string resolve: (response: RuntimeRpcResponse) => void reject: (error: Error) => void @@ -41,9 +64,25 @@ type SubscriptionCallbacks = { type RuntimeSubscription = { id: string method: string - params: unknown + paramsJson: string | undefined + paramsByteLength: number callbacks: SubscriptionCallbacks needsReplay: boolean + releaseRetainedBytes: () => void + cancelQueuedFrame: () => boolean +} + +type PreparedSubscriptionInput = { + paramsJson: string | undefined + paramsByteLength: number + retainedBytes: number + teardownKey: string + worktree: string +} + +type WebRuntimeOutboundFrame = { + bytes: number + payload: string | Uint8Array } export type WebRuntimeSubscriptionHandle = { @@ -66,6 +105,29 @@ const SHARED_CONNECTION_SUBSCRIPTION_METHODS = new Set(['files.watch']) const HEARTBEAT_INTERVAL_MS = 10_000 const HEARTBEAT_IDLE_MS = 25_000 const HEARTBEAT_PROBE_GRACE_MS = 20_000 +export const WEB_RUNTIME_MAX_CONNECTION_WAITERS = 256 +export const WEB_RUNTIME_MAX_PENDING_REQUESTS = 256 +export const WEB_RUNTIME_MAX_SUBSCRIPTIONS = 256 +export const WEB_RUNTIME_MAX_CHILD_CLIENTS = 64 +export const WEB_RUNTIME_MAX_BINARY_FRAME_BYTES = 64 * 1024 * 1024 +export const WEB_RUNTIME_MAX_ENCRYPTED_TEXT_FRAME_BYTES = MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS +export const WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES = 4 * 1024 * 1024 +export const WEB_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES = 1024 * 1024 +export const WEB_RUNTIME_MAX_RPC_METHOD_BYTES = 8 * 1024 +export const WEB_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES = 8 * 1024 * 1024 + +const WEB_RUNTIME_OUTBOUND_SOCKET_SOFT_CAP_BYTES = 8 * 1024 * 1024 +const WEB_RUNTIME_MAX_OUTBOUND_WIRE_FRAME_BYTES = WEB_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES + 64 + +const WEB_RUNTIME_BUSY_MESSAGE = 'Remote Orca runtime client is busy; retry after requests finish.' +const RPC_PARAMS_MEMBER_PREFIX = ',"params":' +const cancelNothing = (): boolean => false +const releaseNothing = (): void => undefined +const REJECTED_OUTBOUND_ENQUEUE: WsOutboundEnqueueResult = { + accepted: false, + queued: false, + cancel: cancelNothing +} export class WebRuntimeClient { private ws: WebSocket | null = null @@ -89,8 +151,15 @@ export class WebRuntimeClient { private readonly childClients = new Set() private readonly waiters: { resolve: () => void; reject: (error: Error) => void }[] = [] private readonly serverPublicKey: Uint8Array + private outboundQueue: WsOutboundBackpressureQueue | null = null + private outboundSocketMemory: WebRuntimeOutboundSocketMemory | null = null + private activeCallAdmissions = 0 + private pendingSubscriptionAdmissions = 0 - constructor(private readonly pairing: WebPairingOffer) { + constructor( + private readonly pairing: WebPairingOffer, + private readonly outboundMemoryBudget: WebRuntimeOutboundMemoryBudget = createWebRuntimeOutboundMemoryBudget() + ) { this.serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) this.openConnection() } @@ -100,21 +169,66 @@ export class WebRuntimeClient { params?: unknown, options?: { timeoutMs?: number } ): Promise> { - await this.waitForConnected(options?.timeoutMs) - return new Promise((resolve, reject) => { + assertRpcMethodWithinLimit(method) + const releaseCallAdmission = this.claimCallAdmission() + let releasePreparedBytes: (() => void) | null = null + let serialized: string | undefined + try { const id = this.nextId() - const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS - const timeout = window.setTimeout(() => { - this.pending.delete(id) - reject(new Error(`Request timed out: ${method}`)) - }, timeoutMs) - this.pending.set(id, { method, resolve, reject, timeout }) - if (!this.sendEncrypted({ id, deviceToken: this.pairing.deviceToken, method, params })) { + serialized = stringifyWebRuntimeOutboundJson( + { id, deviceToken: this.pairing.deviceToken, method, params }, + WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ).serialized + params = undefined + releasePreparedBytes = this.outboundMemoryBudget.claimPreparedRpcBytes( + retainedPreparedFrameBytes(serialized, method) + ) + if (!releasePreparedBytes) { + throw new Error(WEB_RUNTIME_BUSY_MESSAGE) + } + await this.waitForConnected(options?.timeoutMs) + return await new Promise((resolve, reject) => { + if (this.pending.size >= WEB_RUNTIME_MAX_PENDING_REQUESTS) { + reject(new Error(WEB_RUNTIME_BUSY_MESSAGE)) + return + } + const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS + const pending: PendingRequest = { + cancelQueuedFrame: cancelNothing, + method, + resolve, + reject, + timeout: 0 + } + const timeout = window.setTimeout(() => { + if (this.pending.get(id) !== pending) { + return + } + pending.cancelQueuedFrame() + this.pending.delete(id) + reject(new Error(`Request timed out: ${method}`)) + }, timeoutMs) + pending.timeout = timeout + this.pending.set(id, pending) + const sent = serialized + ? this.sendEncryptedSerialized(serialized) + : REJECTED_OUTBOUND_ENQUEUE + serialized = undefined + pending.cancelQueuedFrame = sent.cancel + releasePreparedBytes?.() + releasePreparedBytes = null + if (sent.accepted) { + return + } this.pending.delete(id) window.clearTimeout(timeout) - reject(new Error('Remote Orca runtime is not connected.')) - } - }) + reject(new Error('Remote Orca runtime could not send the request.')) + }) + } finally { + serialized = undefined + releasePreparedBytes?.() + releaseCallAdmission() + } } async subscribe( @@ -123,56 +237,97 @@ export class WebRuntimeClient { callbacks: SubscriptionCallbacks, options?: SubscribeOptions ): Promise { - if (SHARED_CONNECTION_SUBSCRIPTION_METHODS.has(method)) { - // Why: sharing the main socket for file watches avoids exhausting the server's WebSocket connection cap. - return this.subscribeSharedFileWatch(params, callbacks, options) - } - const client = new WebRuntimeClient(this.pairing) - this.childClients.add(client) - const closeChild = (notifySubscriptions = false): void => { - this.childClients.delete(client) - client.close({ notifySubscriptions }) + assertRpcMethodWithinLimit(method) + const sharedConnection = SHARED_CONNECTION_SUBSCRIPTION_METHODS.has(method) + if (!sharedConnection && this.childClients.size >= WEB_RUNTIME_MAX_CHILD_CLIENTS) { + throw new Error(WEB_RUNTIME_BUSY_MESSAGE) } + const releaseAdmission = this.claimSubscriptionAdmission() + let releaseRetainedBytes: (() => void) | null = null + let ownershipTransferred = false try { + const preparedInput = prepareSubscriptionInput(method, params) + params = undefined + releaseRetainedBytes = this.outboundMemoryBudget.claimSubscriptionBytes( + preparedInput.retainedBytes + ) + if (!releaseRetainedBytes) { + throw new Error(WEB_RUNTIME_BUSY_MESSAGE) + } + if (sharedConnection) { + // Why: sharing the main socket for file watches avoids exhausting the server's WebSocket connection cap. + const handle = await this.subscribeSharedFileWatch( + preparedInput, + releaseRetainedBytes, + callbacks, + options + ) + ownershipTransferred = true + return handle + } + const client = new WebRuntimeClient(this.pairing, this.outboundMemoryBudget) + this.childClients.add(client) + const closeChild = (notifySubscriptions = false): void => { + this.childClients.delete(client) + client.close({ notifySubscriptions }) + } const wrappedCallbacks: SubscriptionCallbacks = { ...callbacks, onError: (error) => { - callbacks.onError?.(error) closeChild() + invokeConsumerCallback(() => callbacks.onError?.(error)) }, onClose: () => { - callbacks.onClose?.() closeChild() + invokeConsumerCallback(() => callbacks.onClose?.()) } } - const handle = await client.subscribeOnCurrentConnection( - method, - params, - wrappedCallbacks, - options - ) + let handle: WebRuntimeSubscriptionHandle + try { + handle = await client.subscribePreparedOnCurrentConnection( + method, + preparedInput, + releaseRetainedBytes, + wrappedCallbacks, + options + ) + } catch (error) { + closeChild() + throw error + } + ownershipTransferred = true + preparedInput.paramsJson = undefined + preparedInput.teardownKey = '' return { unsubscribe: () => { // Why: emit the teardown RPC before closing the child socket so the server reaps the fs-watcher on view-toggle. - handle.unsubscribe() - closeChild() + try { + handle.unsubscribe() + } finally { + closeChild() + } }, sendBinary: (bytes) => handle.sendBinary(bytes) } - } catch (error) { - closeChild() - throw error + } finally { + if (!ownershipTransferred) { + releaseRetainedBytes?.() + } + releaseAdmission() } } private async subscribeSharedFileWatch( - params: unknown, + preparedInput: PreparedSubscriptionInput, + releaseRetainedBytes: () => void, callbacks: SubscriptionCallbacks, options?: { timeoutMs?: number } ): Promise { - const teardownKey = JSON.stringify(params) ?? String(params) + const initialTeardownKey = preparedInput.teardownKey + let teardownKey: string | null = initialTeardownKey + const worktree = preparedInput.worktree await Promise.all( - Array.from(this.fileWatchTeardownRetries.get(teardownKey) ?? [], (retry) => retry()) + Array.from(this.fileWatchTeardownRetries.get(initialTeardownKey) ?? [], (retry) => retry()) ) let stopped = false let remoteSubscriptionId: string | null = null @@ -198,12 +353,14 @@ export class WebRuntimeClient { if (response.ok === false) { throw new Error(`${response.error.code}: ${response.error.message}`) } - const retries = this.fileWatchTeardownRetries.get(teardownKey) + const key = teardownKey + const retries = key ? this.fileWatchTeardownRetries.get(key) : undefined retries?.delete(retryRemoteUnwatch) if (retries?.size === 0) { - this.fileWatchTeardownRetries.delete(teardownKey) + this.fileWatchTeardownRetries.delete(key!) } dropLocalSubscription() + teardownKey = null }) .catch((error: unknown) => { console.warn('Failed to unwatch remote file subscription:', error) @@ -225,9 +382,14 @@ export class WebRuntimeClient { return } // Why: retain the callback and retry until the server acks physical teardown; a new watch joins this barrier. - const retries = this.fileWatchTeardownRetries.get(teardownKey) ?? new Set() + const key = teardownKey + if (!key) { + dropLocalSubscription() + return + } + const retries = this.fileWatchTeardownRetries.get(key) ?? new Set() retries.add(retryRemoteUnwatch) - this.fileWatchTeardownRetries.set(teardownKey, retries) + this.fileWatchTeardownRetries.set(key, retries) void retryRemoteUnwatch().catch(() => {}) } const wrappedCallbacks: SubscriptionCallbacks = { @@ -247,11 +409,13 @@ export class WebRuntimeClient { return } if (!stopped) { - callbacks.onResponse(response) + invokeConsumerCallback(() => callbacks.onResponse(response)) if (pendingReplayResync && nextSubscriptionId && response.ok) { pendingReplayResync = false // Why: a replayed watch only reports events after its own setup, so consumers must re-scan the reconnect gap. - callbacks.onResponse(createFileWatchReplayOverflowResponse(response, params)) + invokeConsumerCallback(() => + callbacks.onResponse(createFileWatchReplayOverflowResponse(response, worktree)) + ) } } else if (response.ok === false) { dropLocalSubscription() @@ -259,12 +423,12 @@ export class WebRuntimeClient { }, onError: (error) => { if (!stopped) { - callbacks.onError?.(error) + invokeConsumerCallback(() => callbacks.onError?.(error)) } }, onClose: () => { if (!stopped) { - callbacks.onClose?.() + invokeConsumerCallback(() => callbacks.onClose?.()) } }, onTransportInterrupted: () => { @@ -273,25 +437,30 @@ export class WebRuntimeClient { if (!stopped) { return } - const retries = this.fileWatchTeardownRetries.get(teardownKey) + const key = teardownKey + const retries = key ? this.fileWatchTeardownRetries.get(key) : undefined retries?.delete(retryRemoteUnwatch) if (retries?.size === 0) { - this.fileWatchTeardownRetries.delete(teardownKey) + this.fileWatchTeardownRetries.delete(key!) } // Why: socket close physically releases the server subscription — a stopped watch must not replay on the replacement. dropLocalSubscription() + teardownKey = null }, onTransportReplayed: () => { transportInterrupted = false pendingReplayResync = true } } - handle = await this.subscribeOnCurrentConnection( + handle = await this.subscribePreparedOnCurrentConnection( 'files.watch', - params, + preparedInput, + releaseRetainedBytes, wrappedCallbacks, options ) + preparedInput.paramsJson = undefined + preparedInput.teardownKey = '' return { unsubscribe: () => { @@ -311,25 +480,88 @@ export class WebRuntimeClient { } } - private async subscribeOnCurrentConnection( + protected async subscribeOnCurrentConnection( method: string, params: unknown, callbacks: SubscriptionCallbacks, options?: SubscribeOptions + ): Promise { + assertRpcMethodWithinLimit(method) + const releaseAdmission = this.claimSubscriptionAdmission() + let releaseRetainedBytes: (() => void) | null = null + let ownershipTransferred = false + try { + const preparedInput = prepareSubscriptionInput(method, params) + params = undefined + releaseRetainedBytes = this.outboundMemoryBudget.claimSubscriptionBytes( + preparedInput.retainedBytes + ) + if (!releaseRetainedBytes) { + throw new Error(WEB_RUNTIME_BUSY_MESSAGE) + } + const handle = await this.subscribePreparedOnCurrentConnection( + method, + preparedInput, + releaseRetainedBytes, + callbacks, + options + ) + ownershipTransferred = true + preparedInput.paramsJson = undefined + preparedInput.teardownKey = '' + return handle + } finally { + if (!ownershipTransferred) { + releaseRetainedBytes?.() + } + releaseAdmission() + } + } + + private async subscribePreparedOnCurrentConnection( + method: string, + preparedInput: PreparedSubscriptionInput, + releaseRetainedBytes: () => void, + callbacks: SubscriptionCallbacks, + options?: SubscribeOptions ): Promise { await this.waitForConnected(options?.timeoutMs) + if (this.subscriptions.size >= WEB_RUNTIME_MAX_SUBSCRIPTIONS) { + throw new Error(WEB_RUNTIME_BUSY_MESSAGE) + } const id = this.nextId() - const subscription: RuntimeSubscription = { id, method, params, callbacks, needsReplay: false } + const serialized = serializePreparedRpcFrame({ + id, + deviceToken: this.pairing.deviceToken, + method, + paramsJson: preparedInput.paramsJson, + paramsByteLength: preparedInput.paramsByteLength + }) + const subscription: RuntimeSubscription = { + id, + method, + paramsJson: preparedInput.paramsJson, + paramsByteLength: preparedInput.paramsByteLength, + callbacks, + needsReplay: false, + releaseRetainedBytes, + cancelQueuedFrame: cancelNothing + } this.subscriptions.set(id, subscription) - if (!this.sendEncrypted({ id, deviceToken: this.pairing.deviceToken, method, params })) { - this.subscriptions.delete(id) - throw new Error('Remote Orca runtime is not connected.') + const sent = this.sendEncryptedSerialized(serialized) + subscription.cancelQueuedFrame = sent.cancel + if (!sent.accepted) { + this.removeSubscription(id, subscription) + throw new Error('Remote Orca runtime could not send the subscription.') } return { unsubscribe: () => { - this.subscriptions.delete(subscription.id) + const paramsJson = subscription.paramsJson + if (!this.removeSubscription(subscription.id, subscription)) { + return + } // Tell the server to reap its keyed cleanup before the socket closes; best-effort (a closed socket already reaps). - const teardown = options?.buildUnsubscribe?.(params) + const teardown = options?.buildUnsubscribe?.(parseSerializedParams(paramsJson)) if (teardown) { this.sendEncrypted({ id: this.nextId(), @@ -348,25 +580,36 @@ export class WebRuntimeClient { close(options: { notifySubscriptions?: boolean } = {}): void { const shouldNotifySubscriptions = options.notifySubscriptions ?? true this.intentionallyClosed = true - for (const child of Array.from(this.childClients)) { - child.close({ notifySubscriptions: shouldNotifySubscriptions }) - } + const children = Array.from(this.childClients) this.childClients.clear() this.fileWatchTeardownRetries.clear() this.clearTimers() + const ws = this.ws + this.ws = null + this.sharedKey = null + this.disposeOutboundTransport() + if (ws) { + try { + ws.close() + } catch { + // The client is already detached; a browser close failure must not retain transport state. + } + } this.rejectAllPending('Remote Orca runtime connection closed.') this.rejectAllWaiters(new Error('Remote Orca runtime connection closed.')) + this.setState('disconnected') + for (const child of children) { + try { + child.close({ notifySubscriptions: shouldNotifySubscriptions }) + } catch { + // Continue closing sibling transports even if one child cleanup fails. + } + } if (shouldNotifySubscriptions) { this.notifySubscriptionsClosed() } else { - this.subscriptions.clear() + this.clearSubscriptions() } - if (this.ws) { - this.ws.close() - this.ws = null - } - this.sharedKey = null - this.setState('disconnected') } private openConnection(): void { @@ -381,6 +624,15 @@ export class WebRuntimeClient { this.scheduleReconnect() return } + try { + this.outboundSocketMemory = this.outboundMemoryBudget.registerBufferedAmount( + () => ws.bufferedAmount + ) + } catch { + ws.close() + this.scheduleReconnect() + return + } ws.binaryType = 'arraybuffer' this.ws = ws @@ -402,6 +654,7 @@ export class WebRuntimeClient { this.setState('handshaking') const keyPair = generateKeyPair() this.sharedKey = deriveSharedKey(keyPair.secretKey, this.serverPublicKey) + this.ensureOutboundQueue(ws) ws.send( JSON.stringify({ type: 'e2ee_hello', @@ -443,17 +696,28 @@ export class WebRuntimeClient { private async handleSocketMessage(rawData: unknown, sourceWs?: WebSocket): Promise { const raw = typeof rawData === 'string' ? rawData : null + if (raw !== null && raw.length > WEB_RUNTIME_MAX_ENCRYPTED_TEXT_FRAME_BYTES) { + const offendingSocket = sourceWs ?? this.ws + if (offendingSocket) { + this.failOutboundSocket(offendingSocket) + } + return + } if (this.state === 'handshaking') { if (raw === null || !this.sharedKey) { return } try { - const control = JSON.parse(raw) as { type?: unknown } + const control = parseWebRuntimeInboundJson<{ type?: unknown }>(raw) if (control.type === 'e2ee_ready') { this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken }) return } - } catch { + } catch (error) { + if (isWebRuntimeJsonStructureCapacityError(error)) { + this.failInboundJsonCapacity(sourceWs) + return + } // The authenticated control frame is encrypted, so non-JSON is normal here. } @@ -462,22 +726,22 @@ export class WebRuntimeClient { return } try { - const control = JSON.parse(plaintext) as { + const control = parseWebRuntimeInboundJson<{ type?: unknown error?: { code?: string; message?: string } - } + }>(plaintext) if (control.type === 'e2ee_authenticated') { this.clearHandshakeTimer() this.reconnectAttempt = 0 this.setState('connected') } else if (control.type === 'e2ee_error' || control.error?.code === 'unauthorized') { - this.intentionallyClosed = true - this.setState('auth-failed') - this.rejectAllPending('Unauthorized. Pair this web client again.') - this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.') - this.ws?.close() + this.handleAuthenticationFailure() + } + } catch (error) { + if (isWebRuntimeJsonStructureCapacityError(error)) { + this.failInboundJsonCapacity(sourceWs) + return } - } catch { // Ignore malformed handshake payloads; the server will close on timeout. } return @@ -499,8 +763,8 @@ export class WebRuntimeClient { if (!plaintext) { return } - for (const subscription of this.subscriptions.values()) { - subscription.callbacks.onBinary?.(plaintext) + for (const subscription of Array.from(this.subscriptions.values())) { + invokeConsumerCallback(() => subscription.callbacks.onBinary?.(plaintext)) } return } @@ -512,8 +776,13 @@ export class WebRuntimeClient { let response: RuntimeRpcResponse | Record try { - response = JSON.parse(plaintext) as RuntimeRpcResponse | Record - } catch { + response = parseWebRuntimeInboundJson | Record>( + plaintext + ) + } catch (error) { + if (isWebRuntimeJsonStructureCapacityError(error)) { + this.failInboundJsonCapacity(sourceWs) + } return } if (isKeepaliveFrame(response)) { @@ -523,26 +792,23 @@ export class WebRuntimeClient { return } if (isRuntimeFailureResponse(response) && response.error.code === 'unauthorized') { - this.intentionallyClosed = true - this.setState('auth-failed') - this.rejectAllPending('Unauthorized. Pair this web client again.') - this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.') - this.ws?.close() + this.handleAuthenticationFailure() return } const subscription = this.subscriptions.get(response.id) if (subscription) { const subscriptionResponse = response as RuntimeRpcResponse - // Why: setup failures must be evicted before callbacks so reconnect cannot replay them. - if (subscriptionResponse.ok === false) { - this.subscriptions.delete(response.id) + subscription.cancelQueuedFrame = cancelNothing + const ended = subscriptionResponse.ok && isEndResult(subscriptionResponse.result) + // Why: terminal subscriptions must release replay payloads before consumer code can throw or reconnect. + if (subscriptionResponse.ok === false || ended) { + this.removeSubscription(response.id, subscription) } // Why: subscription-backed unary RPCs can return ordinary success frames. - subscription.callbacks.onResponse(subscriptionResponse) - if (subscriptionResponse.ok && isEndResult(subscriptionResponse.result)) { - this.subscriptions.delete(response.id) - subscription.callbacks.onClose?.() + invokeConsumerCallback(() => subscription.callbacks.onResponse(subscriptionResponse)) + if (ended) { + invokeConsumerCallback(() => subscription.callbacks.onClose?.()) } return } @@ -551,27 +817,61 @@ export class WebRuntimeClient { if (!pending) { return } + pending.cancelQueuedFrame() + pending.cancelQueuedFrame = cancelNothing this.pending.delete(response.id) window.clearTimeout(pending.timeout) pending.resolve(response as RuntimeRpcResponse) } private sendEncrypted(message: unknown): boolean { - const ws = this.ws - if (!ws || ws.readyState !== WebSocket.OPEN || !this.sharedKey) { + try { + const { serialized } = stringifyWebRuntimeOutboundJson( + message, + WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + return serialized !== undefined && this.sendEncryptedSerialized(serialized).accepted + } catch { return false } - ws.send(encrypt(JSON.stringify(message), this.sharedKey)) - return true + } + + private sendEncryptedSerialized(serialized: string): WsOutboundEnqueueResult { + const ws = this.ws + if (!ws || ws.readyState !== WebSocket.OPEN || !this.sharedKey) { + return REJECTED_OUTBOUND_ENQUEUE + } + const queue = this.ensureOutboundQueue(ws) + if (!queue) { + return REJECTED_OUTBOUND_ENQUEUE + } + try { + const payload = encrypt(serialized, this.sharedKey) + return queue.enqueueCancelable({ payload, bytes: payload.length }) + } catch { + return REJECTED_OUTBOUND_ENQUEUE + } } private sendEncryptedBinary(bytes: Uint8Array): boolean { const ws = this.ws + if (ws && bytes.byteLength > WEB_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES) { + this.failOutboundSocket(ws) + return false + } if (!ws || ws.readyState !== WebSocket.OPEN || !this.sharedKey) { return false } - ws.send(encryptBytes(bytes, this.sharedKey)) - return true + const queue = this.ensureOutboundQueue(ws) + if (!queue) { + return false + } + try { + const payload = encryptBytes(bytes, this.sharedKey) + return queue.enqueue({ payload, bytes: payload.byteLength }) + } catch { + return false + } } private waitForConnected(timeoutMs = REQUEST_TIMEOUT_MS): Promise { @@ -584,6 +884,9 @@ export class WebRuntimeClient { if (this.intentionallyClosed) { return Promise.reject(new Error('Remote Orca runtime connection closed.')) } + if (this.waiters.length >= WEB_RUNTIME_MAX_CONNECTION_WAITERS) { + return Promise.reject(new Error(WEB_RUNTIME_BUSY_MESSAGE)) + } return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve) @@ -612,10 +915,50 @@ export class WebRuntimeClient { }) } + private claimCallAdmission(): () => void { + if (this.activeCallAdmissions >= WEB_RUNTIME_MAX_PENDING_REQUESTS) { + throw new Error(WEB_RUNTIME_BUSY_MESSAGE) + } + this.activeCallAdmissions += 1 + return releaseOnce(() => { + this.activeCallAdmissions -= 1 + }) + } + + private claimSubscriptionAdmission(): () => void { + if (this.pendingSubscriptionAdmissions >= WEB_RUNTIME_MAX_SUBSCRIPTIONS) { + throw new Error(WEB_RUNTIME_BUSY_MESSAGE) + } + this.pendingSubscriptionAdmissions += 1 + return releaseOnce(() => { + this.pendingSubscriptionAdmissions -= 1 + }) + } + + private handleAuthenticationFailure(): void { + this.intentionallyClosed = true + this.clearTimers() + this.setState('auth-failed') + const ws = this.ws + this.ws = null + this.sharedKey = null + this.disposeOutboundTransport() + if (ws) { + try { + ws.close() + } catch { + // Authentication failure already detached the socket and released its memory claims. + } + } + this.rejectAllPending('Unauthorized. Pair this web client again.') + this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.') + } + private handleSocketClosed(closedWs: WebSocket): void { if (this.ws !== closedWs) { return } + this.disposeOutboundTransport() this.ws = null this.sharedKey = null this.clearConnectTimer() @@ -631,6 +974,69 @@ export class WebRuntimeClient { this.scheduleReconnect() } + private ensureOutboundQueue( + ws: WebSocket + ): WsOutboundBackpressureQueue | null { + if (this.outboundQueue) { + return this.outboundQueue + } + if (!this.outboundSocketMemory) { + try { + this.outboundSocketMemory = this.outboundMemoryBudget.registerBufferedAmount( + () => ws.bufferedAmount + ) + } catch { + return null + } + } + const socketMemory = this.outboundSocketMemory + this.outboundQueue = createWsOutboundBackpressureQueue({ + send: (frame) => { + try { + ws.send(frame.payload) + } catch { + this.failOutboundSocket(ws) + } + }, + byteLengthOf: (frame) => frame.bytes, + getBufferedAmount: () => ws.bufferedAmount, + isWritable: () => this.ws === ws && ws.readyState === WebSocket.OPEN && !!this.sharedKey, + canSend: (bytes) => socketMemory.canSend(bytes), + claimQueuedBytes: (bytes) => this.outboundMemoryBudget.claimQueuedBytes(bytes), + softCapBytes: WEB_RUNTIME_OUTBOUND_SOCKET_SOFT_CAP_BYTES, + maxQueuedBytes: WEB_RUNTIME_OUTBOUND_MAX_QUEUED_BYTES, + maxQueuedFrames: WEB_RUNTIME_OUTBOUND_MAX_QUEUED_FRAMES, + maxFrameBytes: WEB_RUNTIME_MAX_OUTBOUND_WIRE_FRAME_BYTES, + onOverflow: () => this.failOutboundSocket(ws) + }) + return this.outboundQueue + } + + private failOutboundSocket(ws: WebSocket): void { + if (this.ws !== ws) { + return + } + try { + ws.close() + } finally { + this.handleSocketClosed(ws) + } + } + + private failInboundJsonCapacity(sourceWs?: WebSocket): void { + const ws = sourceWs ?? this.ws + if (ws) { + this.failOutboundSocket(ws) + } + } + + private disposeOutboundTransport(): void { + this.outboundQueue?.dispose() + this.outboundQueue = null + this.outboundSocketMemory?.release() + this.outboundSocketMemory = null + } + private scheduleReconnect(): void { if (this.reconnectTimer || this.intentionallyClosed) { return @@ -667,6 +1073,8 @@ export class WebRuntimeClient { for (const [id, pending] of this.pending) { this.pending.delete(id) window.clearTimeout(pending.timeout) + pending.cancelQueuedFrame() + pending.cancelQueuedFrame = cancelNothing pending.reject(error) } } @@ -677,22 +1085,44 @@ export class WebRuntimeClient { } } + private removeSubscription(id: string, expected?: RuntimeSubscription): boolean { + const subscription = this.subscriptions.get(id) + if (!subscription || (expected && subscription !== expected)) { + return false + } + this.subscriptions.delete(id) + subscription.cancelQueuedFrame?.() + subscription.cancelQueuedFrame = cancelNothing + subscription.paramsJson = undefined + subscription.paramsByteLength = 0 + subscription.releaseRetainedBytes?.() + subscription.releaseRetainedBytes = releaseNothing + return true + } + + private clearSubscriptions(): void { + for (const [id, subscription] of Array.from(this.subscriptions)) { + this.removeSubscription(id, subscription) + } + } + private notifySubscriptionsClosed(): void { const subscriptions = Array.from(this.subscriptions.values()) - this.subscriptions.clear() + this.clearSubscriptions() for (const subscription of subscriptions) { - subscription.callbacks.onClose?.() + invokeConsumerCallback(() => subscription.callbacks.onClose?.()) } } private handleInterruptedSubscriptions(): void { for (const [id, subscription] of Array.from(this.subscriptions)) { if (!SHARED_CONNECTION_SUBSCRIPTION_METHODS.has(subscription.method)) { - this.subscriptions.delete(id) - subscription.callbacks.onClose?.() + this.removeSubscription(id, subscription) + invokeConsumerCallback(() => subscription.callbacks.onClose?.()) continue } - subscription.callbacks.onTransportInterrupted?.() + subscription.cancelQueuedFrame = cancelNothing + invokeConsumerCallback(() => subscription.callbacks.onTransportInterrupted?.()) if (this.subscriptions.get(subscription.id) === subscription) { subscription.needsReplay = true } @@ -708,15 +1138,22 @@ export class WebRuntimeClient { subscription.id = this.nextId() subscription.needsReplay = false this.subscriptions.set(subscription.id, subscription) - if ( - this.sendEncrypted({ + let sent = REJECTED_OUTBOUND_ENQUEUE + try { + const serialized = serializePreparedRpcFrame({ id: subscription.id, deviceToken: this.pairing.deviceToken, method: subscription.method, - params: subscription.params + paramsJson: subscription.paramsJson, + paramsByteLength: subscription.paramsByteLength }) - ) { - subscription.callbacks.onTransportReplayed?.() + sent = this.sendEncryptedSerialized(serialized) + } catch { + // A previously admitted subscription stays replayable if a replacement frame cannot be prepared. + } + subscription.cancelQueuedFrame = sent.cancel + if (sent.accepted) { + invokeConsumerCallback(() => subscription.callbacks.onTransportReplayed?.()) } else { subscription.needsReplay = true } @@ -725,9 +1162,9 @@ export class WebRuntimeClient { private notifySubscriptionsError(code: string, message: string): void { const subscriptions = Array.from(this.subscriptions.values()) - this.subscriptions.clear() + this.clearSubscriptions() for (const subscription of subscriptions) { - subscription.callbacks.onError?.({ code, message }) + invokeConsumerCallback(() => subscription.callbacks.onError?.({ code, message })) } } @@ -836,6 +1273,81 @@ export class WebRuntimeClient { } } +function assertRpcMethodWithinLimit(method: string): void { + if ( + measureUtf8ByteLength(method, { stopAfterBytes: WEB_RUNTIME_MAX_RPC_METHOD_BYTES }) + .exceededLimit + ) { + throw new Error(`Remote runtime RPC method exceeds ${WEB_RUNTIME_MAX_RPC_METHOD_BYTES} bytes`) + } +} + +function prepareSubscriptionInput(method: string, params: unknown): PreparedSubscriptionInput { + const prepared = stringifyWebRuntimeOutboundJson(params, WEB_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES) + const canonicalParams = parseSerializedParams(prepared.serialized) + const worktree = (canonicalParams as { worktree?: unknown } | null)?.worktree + const normalizedWorktree = typeof worktree === 'string' ? worktree : '' + return { + paramsJson: prepared.serialized, + paramsByteLength: prepared.byteLength, + retainedBytes: + retainedPreparedFrameBytes(prepared.serialized, method) + normalizedWorktree.length * 2, + teardownKey: prepared.serialized ?? String(canonicalParams), + worktree: normalizedWorktree + } +} + +function serializePreparedRpcFrame(input: { + id: string + deviceToken: string + method: string + paramsJson: string | undefined + paramsByteLength: number +}): string { + const header = stringifyWebRuntimeOutboundJson( + { id: input.id, deviceToken: input.deviceToken, method: input.method }, + WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + if (header.serialized === undefined) { + throw new WebRuntimeOutboundJsonLimitError(WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES) + } + if (input.paramsJson === undefined) { + return header.serialized + } + const totalBytes = header.byteLength + RPC_PARAMS_MEMBER_PREFIX.length + input.paramsByteLength + if (totalBytes > WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES) { + throw new WebRuntimeOutboundJsonLimitError(WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES) + } + return `${header.serialized.slice(0, -1)}${RPC_PARAMS_MEMBER_PREFIX}${input.paramsJson}}` +} + +function parseSerializedParams(serialized: string | undefined): unknown { + return serialized === undefined ? undefined : JSON.parse(serialized) +} + +function retainedPreparedFrameBytes(serialized: string | undefined, method: string): number { + return (serialized?.length ?? 0) * 2 + method.length * 2 + 256 +} + +function releaseOnce(release: () => void): () => void { + let released = false + return () => { + if (released) { + return + } + released = true + release() + } +} + +function invokeConsumerCallback(callback: () => void): void { + try { + callback() + } catch { + // One consumer must not block transport cleanup, replay, or sibling notifications. + } +} + function isRuntimeFailureResponse( response: RuntimeRpcResponse | Record ): response is RuntimeRpcResponse & { ok: false } { @@ -863,19 +1375,18 @@ function getFileWatchSubscriptionId(response: RuntimeRpcResponse): stri function createFileWatchReplayOverflowResponse( readyResponse: RuntimeRpcSuccess, - params: unknown + worktree: string ): RuntimeRpcSuccess<{ type: 'changed' worktree: string events: { kind: 'overflow'; absolutePath: string }[] }> { - const worktree = (params as { worktree?: unknown } | null)?.worktree return { id: readyResponse.id, ok: true, result: { type: 'changed', - worktree: typeof worktree === 'string' ? worktree : '', + worktree, // Why: overflow consumers re-scan the whole root and ignore the path (client lacks the server-side root here). events: [{ kind: 'overflow', absolutePath: '' }] }, @@ -902,12 +1413,15 @@ async function websocketPayloadToUint8( value: unknown ): Promise | null> { if (value instanceof Uint8Array) { - return value + return value.byteLength <= WEB_RUNTIME_MAX_BINARY_FRAME_BYTES ? value : null } if (value instanceof ArrayBuffer) { - return new Uint8Array(value) + return value.byteLength <= WEB_RUNTIME_MAX_BINARY_FRAME_BYTES ? new Uint8Array(value) : null } if (value instanceof Blob) { + if (value.size > WEB_RUNTIME_MAX_BINARY_FRAME_BYTES) { + return null + } return new Uint8Array(await value.arrayBuffer()) } return null diff --git a/src/renderer/src/web/web-runtime-environment.ts b/src/renderer/src/web/web-runtime-environment.ts index 7aa48f5711d..22bb0edab42 100644 --- a/src/renderer/src/web/web-runtime-environment.ts +++ b/src/renderer/src/web/web-runtime-environment.ts @@ -2,6 +2,7 @@ import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-envi import type { WebPairingOffer } from './web-pairing' import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' +import { parseWebLocalStorageJson, stringifyWebLocalStorageJson } from './web-local-storage-json' export type StoredWebRuntimeEnvironment = Omit & { compatibleEnvironmentIds?: string[] @@ -23,7 +24,7 @@ export function readStoredWebRuntimeEnvironment(): StoredWebRuntimeEnvironment | return null } try { - const parsed = JSON.parse(raw) as StoredWebRuntimeEnvironment + const parsed = parseWebLocalStorageJson(raw) if ( !parsed.id || !parsed.name || @@ -48,7 +49,7 @@ export function readStoredWebRuntimeEnvironment(): StoredWebRuntimeEnvironment | } export function saveStoredWebRuntimeEnvironment(environment: StoredWebRuntimeEnvironment): void { - window.localStorage.setItem(ENVIRONMENT_STORAGE_KEY, JSON.stringify(environment)) + window.localStorage.setItem(ENVIRONMENT_STORAGE_KEY, stringifyWebLocalStorageJson(environment)) } export function clearStoredWebRuntimeEnvironment(): void { diff --git a/src/renderer/src/web/web-runtime-inbound-json.test.ts b/src/renderer/src/web/web-runtime-inbound-json.test.ts new file mode 100644 index 00000000000..9864ba19158 --- /dev/null +++ b/src/renderer/src/web/web-runtime-inbound-json.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + isWebRuntimeJsonStructureCapacityError, + parseWebRuntimeInboundJson +} from './web-runtime-inbound-json' + +describe('web runtime inbound JSON admission', () => { + it('accepts the exact structural-token limit and rejects one more', () => { + const limits = { structuralTokens: 4, nestingDepth: 2 } + + expect(parseWebRuntimeInboundJson('[0,0,0]', limits)).toEqual([0, 0, 0]) + expect(() => parseWebRuntimeInboundJson('[0,0,0,0]', limits)).toThrow( + 'JSON structure exceeds 4 tokens' + ) + }) + + it('accepts the exact nesting limit and rejects one more', () => { + const limits = { structuralTokens: 20, nestingDepth: 3 } + + expect(parseWebRuntimeInboundJson('[[[]]]', limits)).toEqual([[[]]]) + expect(() => parseWebRuntimeInboundJson('[[[[]]]]', limits)).toThrow( + 'JSON nesting exceeds 3 levels' + ) + }) + + it('identifies capacity failures separately from malformed JSON', () => { + const capacityError = captureError(() => + parseWebRuntimeInboundJson('[0,0]', { + structuralTokens: 2, + nestingDepth: 2 + }) + ) + const syntaxError = captureError(() => + parseWebRuntimeInboundJson('[', { + structuralTokens: 2, + nestingDepth: 2 + }) + ) + + expect(isWebRuntimeJsonStructureCapacityError(capacityError)).toBe(true) + expect(isWebRuntimeJsonStructureCapacityError(syntaxError)).toBe(false) + }) +}) + +function captureError(run: () => void): unknown { + try { + run() + return null + } catch (error) { + return error + } +} diff --git a/src/renderer/src/web/web-runtime-inbound-json.ts b/src/renderer/src/web/web-runtime-inbound-json.ts new file mode 100644 index 00000000000..f41f3c9f28c --- /dev/null +++ b/src/renderer/src/web/web-runtime-inbound-json.ts @@ -0,0 +1,24 @@ +import { + assertJsonTextStructureWithinLimits, + JsonTextStructureCapacityError, + type JsonTextStructureLimits +} from '../../../shared/memory-safety/json-text-structure-limit' + +export const WEB_RUNTIME_INBOUND_JSON_STRUCTURE_LIMITS: JsonTextStructureLimits = { + structuralTokens: 1_000_000, + nestingDepth: 128 +} + +export function parseWebRuntimeInboundJson( + content: string, + limits: JsonTextStructureLimits = WEB_RUNTIME_INBOUND_JSON_STRUCTURE_LIMITS +): T { + assertJsonTextStructureWithinLimits(content, limits) + return JSON.parse(content) as T +} + +export function isWebRuntimeJsonStructureCapacityError( + error: unknown +): error is JsonTextStructureCapacityError { + return error instanceof JsonTextStructureCapacityError +} diff --git a/src/renderer/src/web/web-runtime-outbound-json.test.ts b/src/renderer/src/web/web-runtime-outbound-json.test.ts new file mode 100644 index 00000000000..b70a5701904 --- /dev/null +++ b/src/renderer/src/web/web-runtime-outbound-json.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import { + stringifyWebRuntimeOutboundJson, + WebRuntimeOutboundJsonLimitError +} from './web-runtime-outbound-json' + +describe('bounded web runtime JSON serialization', () => { + it('preserves ordinary JSON values and exact byte accounting', () => { + const value = { text: 'hello', escaped: '\u0000\n', items: [1, true, null] } + const serialized = JSON.stringify(value) + + expect(stringifyWebRuntimeOutboundJson(value, 1_000)).toEqual({ + serialized, + byteLength: new TextEncoder().encode(serialized).byteLength + }) + }) + + it('accepts the exact limit and rejects one byte over it', () => { + expect(stringifyWebRuntimeOutboundJson('🐋', 6)).toEqual({ + serialized: '"🐋"', + byteLength: 6 + }) + expect(() => stringifyWebRuntimeOutboundJson('🐋x', 6)).toThrow( + WebRuntimeOutboundJsonLimitError + ) + }) + + it('stops traversal immediately after an oversized member', () => { + const readAfter = vi.fn(() => 'late') + const value = { + payload: 'x'.repeat(100), + get after() { + return readAfter() + } + } + + expect(() => stringifyWebRuntimeOutboundJson(value, 32)).toThrow( + 'Remote runtime JSON payload exceeds 32 bytes' + ) + expect(readAfter).not.toHaveBeenCalled() + }) + + it('matches JSON omission, array null, escaping, and boxed primitive behavior', () => { + const omitted = { ignored: undefined, kept: 1 } + const array = [undefined, () => 'ignored', Symbol('ignored')] + + expect(stringifyWebRuntimeOutboundJson(omitted, 10).serialized).toBe('{"kept":1}') + expect(stringifyWebRuntimeOutboundJson(array, 16).serialized).toBe('[null,null,null]') + expect(stringifyWebRuntimeOutboundJson(new String('\u0000'), 8).serialized).toBe('"\\u0000"') + }) + + it('accepts shared containers at the exact serialized limit', () => { + const sharedObject = { value: 1 } + const sharedArray = [1, 2] + for (const value of [ + [sharedObject, sharedObject], + [sharedArray, sharedArray] + ]) { + const serialized = JSON.stringify(value) + const byteLength = new TextEncoder().encode(serialized).byteLength + expect(stringifyWebRuntimeOutboundJson(value, byteLength)).toEqual({ + serialized, + byteLength + }) + } + }) + + it('rejects oversized raw JSON before visiting later members', () => { + const json = JSON as typeof JSON & { rawJSON?: (value: string) => unknown } + if (!json.rawJSON) { + return + } + const readAfter = vi.fn(() => 1) + const value = { + raw: json.rawJSON(`"${'x'.repeat(100)}"`), + get after() { + return readAfter() + } + } + + expect(() => stringifyWebRuntimeOutboundJson(value, 32)).toThrow( + WebRuntimeOutboundJsonLimitError + ) + expect(readAfter).not.toHaveBeenCalled() + }) + + it('reports root values omitted by JSON.stringify without retaining output', () => { + expect(stringifyWebRuntimeOutboundJson(undefined, 1)).toEqual({ + serialized: undefined, + byteLength: 0 + }) + }) +}) diff --git a/src/renderer/src/web/web-runtime-outbound-json.ts b/src/renderer/src/web/web-runtime-outbound-json.ts new file mode 100644 index 00000000000..cab93e30a4a --- /dev/null +++ b/src/renderer/src/web/web-runtime-outbound-json.ts @@ -0,0 +1,161 @@ +import { measureUtf8ByteLength } from '../../../shared/memory-safety/utf8-byte-limits' + +export class WebRuntimeOutboundJsonLimitError extends Error { + constructor(readonly maxBytes: number) { + super(`Remote runtime JSON payload exceeds ${maxBytes} bytes`) + this.name = 'WebRuntimeOutboundJsonLimitError' + } +} + +export type WebRuntimeOutboundJson = { + byteLength: number + serialized: string | undefined +} + +export function stringifyWebRuntimeOutboundJson( + value: unknown, + maxBytes: number +): WebRuntimeOutboundJson { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new Error('Remote runtime JSON limit must be a positive safe integer') + } + let estimatedBytes = 0 + let root = true + const emittedMembers = new WeakMap() + const serialized = JSON.stringify(value, function (key, item: unknown) { + const isRoot = root + root = false + const parent = this as object + const inArray = Array.isArray(parent) + if (isRoot && isOmittedObjectValue(item)) { + return item + } + if (!isRoot && !inArray && isOmittedObjectValue(item)) { + return item + } + if (!isRoot) { + const emitted = emittedMembers.get(parent) ?? 0 + estimatedBytes += emitted > 0 ? 1 : 0 + if (!inArray) { + estimatedBytes += escapedJsonStringBytes(key) + 1 + } + emittedMembers.set(parent, emitted + 1) + } + estimatedBytes += + inArray && isOmittedObjectValue(item) + ? 4 + : jsonValueBytes(item, Math.max(0, maxBytes - estimatedBytes)) + if (estimatedBytes > maxBytes) { + throw new WebRuntimeOutboundJsonLimitError(maxBytes) + } + if (typeof item === 'object' && item !== null) { + // Why: the same container can appear twice, and each traversal starts with no emitted members. + emittedMembers.set(item, 0) + } + return item + }) + if (serialized === undefined) { + return { serialized, byteLength: 0 } + } + const measured = measureUtf8ByteLength(serialized, { stopAfterBytes: maxBytes }) + if (measured.exceededLimit) { + throw new WebRuntimeOutboundJsonLimitError(maxBytes) + } + return { serialized, byteLength: measured.byteLength } +} + +function isOmittedObjectValue(value: unknown): boolean { + return value === undefined || typeof value === 'function' || typeof value === 'symbol' +} + +function jsonValueBytes(value: unknown, stopAfterBytes: number): number { + if (value === null) { + return 4 + } + if (typeof value === 'string') { + return escapedJsonStringBytes(value) + } + if (typeof value === 'boolean') { + return value ? 4 : 5 + } + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value).length : 4 + } + if (typeof value === 'object' && value !== null) { + return rawJsonBytes(value, stopAfterBytes) ?? boxedPrimitiveJsonBytes(value) ?? 2 + } + return 4 +} + +function rawJsonBytes(value: object, stopAfterBytes: number): number | null { + const json = JSON as typeof JSON & { isRawJSON?: (candidate: unknown) => boolean } + if (json.isRawJSON?.(value) !== true) { + return null + } + const rawJson = (value as { rawJSON?: unknown }).rawJSON + if (typeof rawJson !== 'string') { + return stopAfterBytes + 1 + } + return measureUtf8ByteLength(rawJson, { stopAfterBytes }).byteLength +} + +function boxedPrimitiveJsonBytes(value: object): number | null { + try { + return escapedJsonStringBytes(String.prototype.valueOf.call(value)) + } catch {} + try { + const number = Number.prototype.valueOf.call(value) + return Number.isFinite(number) ? String(number).length : 4 + } catch {} + try { + return Boolean.prototype.valueOf.call(value) ? 4 : 5 + } catch { + return null + } +} + +function escapedJsonStringBytes(value: string): number { + let bytes = 2 + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if ( + code === 0x22 || + code === 0x5c || + code === 0x08 || + code === 0x09 || + code === 0x0a || + code === 0x0c || + code === 0x0d + ) { + bytes += 2 + } else if ( + code <= 0x1f || + (code >= 0xd800 && code <= 0xdfff && !isSurrogatePair(value, index)) + ) { + bytes += 6 + } else if (code <= 0x7f) { + bytes += 1 + } else if (code <= 0x7ff) { + bytes += 2 + } else if (code >= 0xd800 && code <= 0xdbff) { + bytes += 4 + index += 1 + } else { + bytes += 3 + } + } + return bytes +} + +function isSurrogatePair(value: string, index: number): boolean { + const code = value.charCodeAt(index) + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + return next >= 0xdc00 && next <= 0xdfff + } + if (code >= 0xdc00 && code <= 0xdfff) { + const previous = value.charCodeAt(index - 1) + return previous >= 0xd800 && previous <= 0xdbff + } + return false +} diff --git a/src/renderer/src/web/web-runtime-outbound-memory-budget.test.ts b/src/renderer/src/web/web-runtime-outbound-memory-budget.test.ts new file mode 100644 index 00000000000..08ff5fbf9ec --- /dev/null +++ b/src/renderer/src/web/web-runtime-outbound-memory-budget.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { createWebRuntimeOutboundMemoryBudget } from './web-runtime-outbound-memory-budget' + +describe('web runtime outbound memory budget', () => { + it('bounds aggregate queued bytes and frames with capacity recovery', () => { + const budget = createWebRuntimeOutboundMemoryBudget({ + maxQueuedBytes: 10, + maxQueuedFrames: 2 + }) + const first = budget.claimQueuedBytes(5) + const second = budget.claimQueuedBytes(5) + + expect(first).not.toBeNull() + expect(second).not.toBeNull() + expect(budget.claimQueuedBytes(0)).toBeNull() + first?.() + expect(budget.claimQueuedBytes(5)).not.toBeNull() + }) + + it('accepts exact retained subscription bytes and releases them', () => { + const budget = createWebRuntimeOutboundMemoryBudget({ maxSubscriptionBytes: 10 }) + const release = budget.claimSubscriptionBytes(10) + + expect(release).not.toBeNull() + expect(budget.claimSubscriptionBytes(1)).toBeNull() + release?.() + expect(budget.claimSubscriptionBytes(10)).not.toBeNull() + }) + + it('bounds prepared RPC bytes across connection waiters and releases them', () => { + const budget = createWebRuntimeOutboundMemoryBudget({ maxPreparedRpcBytes: 10 }) + const release = budget.claimPreparedRpcBytes(10) + + expect(release).not.toBeNull() + expect(budget.claimPreparedRpcBytes(1)).toBeNull() + release?.() + expect(budget.claimPreparedRpcBytes(10)).not.toBeNull() + }) + + it('accounts native buffered amounts across registered child sockets', () => { + const budget = createWebRuntimeOutboundMemoryBudget({ + maxBufferedBytes: 10, + maxSocketSources: 2 + }) + let firstBytes = 6 + let secondBytes = 3 + const first = budget.registerBufferedAmount(() => firstBytes) + const second = budget.registerBufferedAmount(() => secondBytes) + + expect(first.canSend(1)).toBe(true) + expect(first.canSend(2)).toBe(false) + secondBytes = 0 + expect(first.canSend(4)).toBe(true) + firstBytes = 0 + second.release() + expect(first.canSend(10)).toBe(true) + }) + + it('caps tracked sockets and recovers when a socket closes', () => { + const budget = createWebRuntimeOutboundMemoryBudget({ maxSocketSources: 1 }) + const socket = budget.registerBufferedAmount(() => 0) + + expect(() => budget.registerBufferedAmount(() => 0)).toThrow('socket tracking limit') + socket.release() + expect(() => budget.registerBufferedAmount(() => 0)).not.toThrow() + }) +}) diff --git a/src/renderer/src/web/web-runtime-outbound-memory-budget.ts b/src/renderer/src/web/web-runtime-outbound-memory-budget.ts new file mode 100644 index 00000000000..48c19e87b05 --- /dev/null +++ b/src/renderer/src/web/web-runtime-outbound-memory-budget.ts @@ -0,0 +1,125 @@ +export const WEB_RUNTIME_OUTBOUND_MAX_BUFFERED_BYTES = 16 * 1024 * 1024 +export const WEB_RUNTIME_OUTBOUND_MAX_QUEUED_BYTES = 32 * 1024 * 1024 +export const WEB_RUNTIME_OUTBOUND_MAX_QUEUED_FRAMES = 4_096 +export const WEB_RUNTIME_OUTBOUND_MAX_SOCKET_SOURCES = 65 +export const WEB_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES = 16 * 1024 * 1024 +export const WEB_RUNTIME_MAX_PREPARED_RPC_BYTES = 32 * 1024 * 1024 + +export type WebRuntimeOutboundSocketMemory = { + canSend: (bytes: number) => boolean + release: () => void +} + +export type WebRuntimeOutboundMemoryBudget = { + claimQueuedBytes: (bytes: number) => (() => void) | null + claimPreparedRpcBytes: (bytes: number) => (() => void) | null + claimSubscriptionBytes: (bytes: number) => (() => void) | null + registerBufferedAmount: (readBufferedAmount: () => number) => WebRuntimeOutboundSocketMemory +} + +export function createWebRuntimeOutboundMemoryBudget(options?: { + maxBufferedBytes?: number + maxQueuedBytes?: number + maxQueuedFrames?: number + maxPreparedRpcBytes?: number + maxSocketSources?: number + maxSubscriptionBytes?: number +}): WebRuntimeOutboundMemoryBudget { + const maxBufferedBytes = options?.maxBufferedBytes ?? WEB_RUNTIME_OUTBOUND_MAX_BUFFERED_BYTES + const maxQueuedBytes = options?.maxQueuedBytes ?? WEB_RUNTIME_OUTBOUND_MAX_QUEUED_BYTES + const maxQueuedFrames = options?.maxQueuedFrames ?? WEB_RUNTIME_OUTBOUND_MAX_QUEUED_FRAMES + const maxPreparedRpcBytes = options?.maxPreparedRpcBytes ?? WEB_RUNTIME_MAX_PREPARED_RPC_BYTES + const maxSocketSources = options?.maxSocketSources ?? WEB_RUNTIME_OUTBOUND_MAX_SOCKET_SOURCES + const maxSubscriptionBytes = + options?.maxSubscriptionBytes ?? WEB_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES + const bufferedSources = new Set<() => number>() + let queuedBytes = 0 + let queuedFrames = 0 + let preparedRpcBytes = 0 + let subscriptionBytes = 0 + + const bufferedBytes = (): number => { + let total = 0 + for (const read of bufferedSources) { + try { + const value = read() + if (Number.isFinite(value) && value > 0) { + total += value + } + } catch { + // Closed browser sockets can reject late reads before their close callback releases the source. + } + } + return total + } + + return { + claimQueuedBytes(bytes): (() => void) | null { + if ( + !Number.isFinite(bytes) || + bytes < 0 || + queuedFrames >= maxQueuedFrames || + queuedBytes + bytes > maxQueuedBytes + ) { + return null + } + queuedBytes += bytes + queuedFrames += 1 + return createRelease(() => { + queuedBytes -= bytes + queuedFrames -= 1 + }) + }, + claimPreparedRpcBytes(bytes): (() => void) | null { + if (!Number.isFinite(bytes) || bytes < 0 || preparedRpcBytes + bytes > maxPreparedRpcBytes) { + return null + } + preparedRpcBytes += bytes + return createRelease(() => { + preparedRpcBytes -= bytes + }) + }, + claimSubscriptionBytes(bytes): (() => void) | null { + if ( + !Number.isFinite(bytes) || + bytes < 0 || + subscriptionBytes + bytes > maxSubscriptionBytes + ) { + return null + } + subscriptionBytes += bytes + return createRelease(() => { + subscriptionBytes -= bytes + }) + }, + registerBufferedAmount(readBufferedAmount) { + if (bufferedSources.size >= maxSocketSources) { + throw new Error('Remote runtime outbound socket tracking limit exceeded') + } + bufferedSources.add(readBufferedAmount) + let registered = true + return { + canSend: (bytes): boolean => + registered && + Number.isFinite(bytes) && + bytes >= 0 && + bytes <= maxBufferedBytes - bufferedBytes(), + release: createRelease(() => { + registered = false + bufferedSources.delete(readBufferedAmount) + }) + } + } + } +} + +function createRelease(release: () => void): () => void { + let released = false + return () => { + if (released) { + return + } + released = true + release() + } +} diff --git a/src/shared/commit-message-generation.ts b/src/shared/commit-message-generation.ts index 7d08242bc81..cd3f8d72a62 100644 --- a/src/shared/commit-message-generation.ts +++ b/src/shared/commit-message-generation.ts @@ -7,6 +7,8 @@ export type CommitMessageDraftContext = { branch: string | null stagedSummary: string stagedPatch: string + /** Workspace-linked GitHub issue number. Omitted entirely when none resolves. */ + linkedIssue?: number | null } export type CommitMessageDraftOptions = { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 13c94faa9c5..e19bbebf1af 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -247,8 +247,13 @@ export function getDefaultSettings(homedir: string): GlobalSettings { // Why: default-on everywhere so it round-trips across platforms; only darwin acts on it. showMenuBarIcon: true, terminalClipboardOnSelect: false, - // Why: OSC 52 is a clipboard data-exfiltration vector; default off (query stays disabled separately). - terminalAllowOsc52Clipboard: false, + // Why: default on so Zellij/tmux/nvim copy works out of the box. Query + // replies stay disabled and payload size is capped in the OSC 52 handler. + // This default only covers new profiles; existing ones persisted `false` + // and are flipped once by the stamp below (shared/osc52-clipboard-settings.ts, + // applied by both the Electron store and the web client's localStorage store). + terminalAllowOsc52Clipboard: true, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true, claudeAgentTeamsMode: 'off', setupScriptLaunchMode: 'new-tab', terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, @@ -484,6 +489,8 @@ export function getDefaultUIState(): PersistedUIState { setupGuideBrowserMilestoneLegacyComplete: false, browserImportHintHidden: false, trayMinimizeNoticeShown: false, + // Why: fresh profiles start on the new default, so nothing was overridden to report. + osc52ClipboardDefaultOnNoticePending: false, mobileEmulatorTabIntroDismissed: false, mobileEmulatorAgentSetupDismissed: false, // Why: only upgraded profiles saw the old ordering, so only they get the one-time notice. diff --git a/src/shared/custom-pet-media-limits.ts b/src/shared/custom-pet-media-limits.ts new file mode 100644 index 00000000000..f3778069e40 --- /dev/null +++ b/src/shared/custom-pet-media-limits.ts @@ -0,0 +1,19 @@ +export const MAX_CUSTOM_PET_FILE_BYTES = 64 * 1024 * 1024 + +// Why: sprite processing holds decoded image, canvas, ImageData, PNG, and +// optional bitmap copies at once, so encoded bytes alone are not a safe bound. +export const MAX_CUSTOM_PET_SHEET_PIXELS = 4 * 1024 * 1024 +export const MAX_CUSTOM_PET_SHEET_DIMENSION = 8_192 +export const MAX_CUSTOM_PET_DETECTED_FRAMES = 128 + +export function isCustomPetSheetSizeSafe(width: number, height: number): boolean { + return ( + Number.isSafeInteger(width) && + Number.isSafeInteger(height) && + width > 0 && + height > 0 && + width <= MAX_CUSTOM_PET_SHEET_DIMENSION && + height <= MAX_CUSTOM_PET_SHEET_DIMENSION && + width * height <= MAX_CUSTOM_PET_SHEET_PIXELS + ) +} diff --git a/src/shared/filesystem-directory-listing-limit.test.ts b/src/shared/filesystem-directory-listing-limit.test.ts new file mode 100644 index 00000000000..73e9feaadf7 --- /dev/null +++ b/src/shared/filesystem-directory-listing-limit.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + assertFilesystemDirectoryWithinLimit, + createFilesystemDirectoryLimitState, + FILESYSTEM_DIRECTORY_LIMIT_MESSAGE, + FILESYSTEM_DIRECTORY_MAX_ENTRIES, + FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES, + resolveFilesystemDirectoryListingLimits, + trackFilesystemDirectoryEntry +} from './filesystem-directory-listing-limit' + +describe('filesystem directory listing limit', () => { + it('accepts ordinary complete listings', () => { + expect(() => + assertFilesystemDirectoryWithinLimit([ + { name: 'src' }, + { name: 'README.md' }, + { name: '文件.txt' } + ]) + ).not.toThrow() + }) + + it('rejects the first entry beyond the count limit', () => { + const state = createFilesystemDirectoryLimitState({ + maxEntries: 2, + maxRetainedBytes: FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES + }) + + trackFilesystemDirectoryEntry(state, { name: 'one' }) + trackFilesystemDirectoryEntry(state, { name: 'two' }) + expect(() => trackFilesystemDirectoryEntry(state, { name: 'three' })).toThrow( + FILESYSTEM_DIRECTORY_LIMIT_MESSAGE + ) + expect(state.entries).toBe(3) + }) + + it('rejects names beyond the retained-byte limit', () => { + const state = createFilesystemDirectoryLimitState({ + maxEntries: FILESYSTEM_DIRECTORY_MAX_ENTRIES, + maxRetainedBytes: 100 + }) + + expect(() => trackFilesystemDirectoryEntry(state, { name: 'xx' })).toThrow( + FILESYSTEM_DIRECTORY_LIMIT_MESSAGE + ) + }) + + it('never lets callers raise the process-wide ceilings', () => { + expect( + resolveFilesystemDirectoryListingLimits({ + maxEntries: Number.MAX_SAFE_INTEGER, + maxRetainedBytes: Number.MAX_SAFE_INTEGER + }) + ).toEqual({ + maxEntries: FILESYSTEM_DIRECTORY_MAX_ENTRIES, + maxRetainedBytes: FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES + }) + }) +}) diff --git a/src/shared/filesystem-directory-listing-limit.ts b/src/shared/filesystem-directory-listing-limit.ts new file mode 100644 index 00000000000..c43f0eebbcf --- /dev/null +++ b/src/shared/filesystem-directory-listing-limit.ts @@ -0,0 +1,75 @@ +export const FILESYSTEM_DIRECTORY_MAX_ENTRIES = 100_000 +export const FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES = 12 * 1024 * 1024 +export const FILESYSTEM_DIRECTORY_LIMIT_MESSAGE = + 'This folder is too large to list safely (limit: 100,000 items or a 12 MB listing).' + +export type FilesystemDirectoryListingLimits = { + maxEntries: number + maxRetainedBytes: number +} + +type NamedDirectoryEntry = { name: string } + +export type FilesystemDirectoryLimitState = { + entries: number + retainedBytes: number + limits: FilesystemDirectoryListingLimits +} + +export function resolveFilesystemDirectoryListingLimits( + requested?: Partial +): FilesystemDirectoryListingLimits { + return { + maxEntries: clampLimit(requested?.maxEntries, FILESYSTEM_DIRECTORY_MAX_ENTRIES), + maxRetainedBytes: clampLimit( + requested?.maxRetainedBytes, + FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES + ) + } +} + +export function createFilesystemDirectoryLimitState( + requested?: Partial +): FilesystemDirectoryLimitState { + return { + entries: 0, + retainedBytes: 0, + limits: resolveFilesystemDirectoryListingLimits(requested) + } +} + +export function trackFilesystemDirectoryEntry( + state: FilesystemDirectoryLimitState, + entry: NamedDirectoryEntry +): void { + state.entries += 1 + state.retainedBytes += estimateFilesystemDirectoryEntryBytes(entry) + if ( + state.entries > state.limits.maxEntries || + state.retainedBytes > state.limits.maxRetainedBytes + ) { + throw new Error(FILESYSTEM_DIRECTORY_LIMIT_MESSAGE) + } +} + +export function assertFilesystemDirectoryWithinLimit( + entries: readonly NamedDirectoryEntry[], + requested?: Partial +): void { + const state = createFilesystemDirectoryLimitState(requested) + for (const entry of entries) { + trackFilesystemDirectoryEntry(state, entry) + } +} + +export function estimateFilesystemDirectoryEntryBytes(entry: NamedDirectoryEntry): number { + // Why: this covers worst-case JSON escaping plus each result object's fixed overhead. + return entry.name.length * 6 + 96 +} + +function clampLimit(value: number | undefined, maximum: number): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + return maximum + } + return Math.min(value, maximum) +} diff --git a/src/shared/hermes-run-output-limits.test.ts b/src/shared/hermes-run-output-limits.test.ts new file mode 100644 index 00000000000..1f583a83ca9 --- /dev/null +++ b/src/shared/hermes-run-output-limits.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { DatabaseSync } from 'node:sqlite' +import { + formatHermesSessionMessagesWithinLimits, + HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR, + HERMES_SESSION_RUN_METADATA_MAX_BYTES, + HERMES_SESSION_RUN_SELECT_SQL, + HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR, + hydrateHermesRunPageWithinLimits +} from './hermes-run-output-limits' + +describe('HERMES_SESSION_RUN_SELECT_SQL', () => { + it('preserves exact-limit text metadata and rejects oversized non-numeric fields in SQL', () => { + const database = new DatabaseSync(':memory:') + database.exec(`CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT, + started_at, + ended_at, + model TEXT, + message_count, + input_tokens, + output_tokens + )`) + const exactTitle = 't'.repeat(HERMES_SESSION_RUN_METADATA_MAX_BYTES) + const exactModel = 'm'.repeat(HERMES_SESSION_RUN_METADATA_MAX_BYTES) + database + .prepare( + `INSERT INTO sessions ( + id, title, started_at, ended_at, model, message_count, input_tokens, output_tokens + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run('run-1', exactTitle, 1, 2, exactModel, 3, 4, 5) + + const exact = database.prepare(HERMES_SESSION_RUN_SELECT_SQL).get('run-1') + expect(exact).toEqual({ + title: exactTitle, + started_at: 1, + ended_at: 2, + model: exactModel, + message_count: 3, + input_tokens: 4, + output_tokens: 5 + }) + + database.exec(`UPDATE sessions SET + title = title || 'overflow', + model = model || 'overflow', + started_at = zeroblob(1048576), + ended_at = zeroblob(1048576), + message_count = zeroblob(1048576), + input_tokens = zeroblob(1048576), + output_tokens = zeroblob(1048576) + WHERE id = 'run-1'`) + const bounded = database.prepare(HERMES_SESSION_RUN_SELECT_SQL).get('run-1') + + expect(Buffer.byteLength(String(bounded?.title))).toBe(HERMES_SESSION_RUN_METADATA_MAX_BYTES) + expect(Buffer.byteLength(String(bounded?.model))).toBe(HERMES_SESSION_RUN_METADATA_MAX_BYTES) + expect(bounded).toMatchObject({ + started_at: null, + ended_at: null, + message_count: null, + input_tokens: null, + output_tokens: null + }) + database.close() + }) +}) + +describe('formatHermesSessionMessagesWithinLimits', () => { + it('preserves transcript formatting below the limits', () => { + const result = formatHermesSessionMessagesWithinLimits([ + { + role: 'assistant', + tool_name: ' terminal ', + reasoning_content: ' because ', + content: ' done ' + }, + { role: null, content: ' ' } + ]) + + expect(result).toEqual({ + content: [ + '## assistant / terminal', + '', + '### Reasoning', + '', + 'because', + '', + 'done', + '', + '---', + '', + '## message', + '', + '(empty)' + ].join('\n'), + truncated: false + }) + }) + + it('keeps UTF-8 output within the byte budget and explains truncation', () => { + const result = formatHermesSessionMessagesWithinLimits( + [{ role: 'assistant', content: '🐋'.repeat(100) }], + { maxBytes: 160 } + ) + + expect(result.truncated).toBe(true) + expect(Buffer.byteLength(result.content ?? '')).toBeLessThanOrEqual(160) + expect(result.content).toContain(HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR) + expect(result.content).not.toContain('\ufffd') + }) + + it('stops pulling rows after the message limit', () => { + let rowsPulled = 0 + function* messages(): Generator> { + for (let index = 0; index < 100; index += 1) { + rowsPulled += 1 + yield { role: 'user', content: `message ${index}` } + } + } + + const result = formatHermesSessionMessagesWithinLimits(messages(), { + maxBytes: 1024, + maxMessages: 2 + }) + + expect(rowsPulled).toBe(3) + expect(result.truncated).toBe(true) + expect(result.content).toContain('message 0') + expect(result.content).toContain('message 1') + expect(result.content).not.toContain('message 2') + }) +}) + +describe('hydrateHermesRunPageWithinLimits', () => { + it('bounds concurrency, preserves order, and omits aggregate overflow', async () => { + let active = 0 + let maxActive = 0 + const refs = ['first', 'second', 'third', 'fourth'] + + const runs = (await hydrateHermesRunPageWithinLimits( + refs, + async (id) => { + active += 1 + maxActive = Math.max(maxActive, active) + await new Promise((resolve) => setTimeout(resolve, 1)) + active -= 1 + return { id, output_content: id.slice(0, 3) } + }, + { maxConcurrent: 2, maxRetainedBytes: 5 } + )) as { id: string; output_content: string | null; error?: string }[] + + expect(maxActive).toBe(2) + expect(runs.map((run) => run.id)).toEqual(refs) + expect(runs[0]?.output_content).toBe('fir') + expect(runs.slice(1)).toEqual( + refs.slice(1).map((id) => ({ + id, + output_content: null, + error: HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR + })) + ) + }) +}) diff --git a/src/shared/hermes-run-output-limits.ts b/src/shared/hermes-run-output-limits.ts new file mode 100644 index 00000000000..99d16feaff0 --- /dev/null +++ b/src/shared/hermes-run-output-limits.ts @@ -0,0 +1,254 @@ +export const HERMES_PRIMARY_OUTPUT_MAX_BYTES = 5 * 1024 * 1024 +export const HERMES_SESSION_TRANSCRIPT_MAX_BYTES = 8 * 1024 * 1024 +export const HERMES_SESSION_TRANSCRIPT_MAX_MESSAGES = 10_000 +export const HERMES_RUN_PAGE_MAX_RETAINED_BYTES = 32 * 1024 * 1024 +export const HERMES_RUN_PAGE_MAX_RUNS = 100 +export const HERMES_RUN_HYDRATION_CONCURRENCY = 2 +export const HERMES_SESSION_RUN_METADATA_MAX_BYTES = 16 * 1024 +export const HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR = + 'Run output omitted because this history page exceeds the memory limit' +export const HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR = + 'Session transcript truncated because it exceeds the 8 MiB or 10,000-message history limit' + +const HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES = HERMES_SESSION_TRANSCRIPT_MAX_BYTES + 1 +const HERMES_SESSION_TRANSCRIPT_TRUNCATION_NOTICE = `[${HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR}.]` +const HERMES_SESSION_TRANSCRIPT_SOURCE_TRUNCATED_COLUMN = 'orca_source_truncated' + +export const HERMES_SESSION_RUN_SELECT_SQL = `SELECT + CASE WHEN typeof(title) = 'text' + THEN CAST(substr(CAST(title AS BLOB), 1, ${HERMES_SESSION_RUN_METADATA_MAX_BYTES}) AS TEXT) + ELSE NULL END AS title, + CASE WHEN typeof(started_at) IN ('integer', 'real') THEN started_at ELSE NULL END AS started_at, + CASE WHEN typeof(ended_at) IN ('integer', 'real') THEN ended_at ELSE NULL END AS ended_at, + CASE WHEN typeof(model) = 'text' + THEN CAST(substr(CAST(model AS BLOB), 1, ${HERMES_SESSION_RUN_METADATA_MAX_BYTES}) AS TEXT) + ELSE NULL END AS model, + CASE WHEN typeof(message_count) IN ('integer', 'real') THEN message_count ELSE NULL END AS message_count, + CASE WHEN typeof(input_tokens) IN ('integer', 'real') THEN input_tokens ELSE NULL END AS input_tokens, + CASE WHEN typeof(output_tokens) IN ('integer', 'real') THEN output_tokens ELSE NULL END AS output_tokens +FROM sessions +WHERE id = ?` + +export const HERMES_SESSION_TRANSCRIPT_SELECT_SQL = `SELECT + CAST(substr(CAST(role AS BLOB), 1, ${HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES}) AS TEXT) AS role, + CAST(substr(CAST(content AS BLOB), 1, ${HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES}) AS TEXT) AS content, + CAST(substr(CAST(tool_name AS BLOB), 1, ${HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES}) AS TEXT) AS tool_name, + CAST(substr(CAST(reasoning AS BLOB), 1, ${HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES}) AS TEXT) AS reasoning, + CAST(substr(CAST(reasoning_content AS BLOB), 1, ${HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES}) AS TEXT) AS reasoning_content, + CASE WHEN + length(CAST(role AS BLOB)) > ${HERMES_SESSION_TRANSCRIPT_MAX_BYTES} + OR length(CAST(content AS BLOB)) > ${HERMES_SESSION_TRANSCRIPT_MAX_BYTES} + OR length(CAST(tool_name AS BLOB)) > ${HERMES_SESSION_TRANSCRIPT_MAX_BYTES} + OR length(CAST(reasoning AS BLOB)) > ${HERMES_SESSION_TRANSCRIPT_MAX_BYTES} + OR length(CAST(reasoning_content AS BLOB)) > ${HERMES_SESSION_TRANSCRIPT_MAX_BYTES} + THEN 1 ELSE 0 END AS ${HERMES_SESSION_TRANSCRIPT_SOURCE_TRUNCATED_COLUMN} +FROM messages +WHERE session_id = ? +ORDER BY timestamp, id +LIMIT ${HERMES_SESSION_TRANSCRIPT_MAX_MESSAGES + 1}` + +type HermesRunPageLimits = { + maxConcurrent?: number + maxRetainedBytes?: number +} + +type HermesSessionTranscriptLimits = { + maxBytes?: number + maxMessages?: number +} + +export type FormattedHermesSessionMessages = { + content: string | null + truncated: boolean +} + +export async function hydrateHermesRunPageWithinLimits( + refs: readonly T[], + hydrate: (ref: T) => Promise, + limits: HermesRunPageLimits = {} +): Promise { + const maxConcurrent = clampFiniteLimit(limits.maxConcurrent, 1, HERMES_RUN_HYDRATION_CONCURRENCY) + const maxRetainedBytes = clampFiniteLimit( + limits.maxRetainedBytes, + 0, + HERMES_RUN_PAGE_MAX_RETAINED_BYTES + ) + const results: unknown[] = [] + let retainedBytes = 0 + for (let start = 0; start < refs.length; start += maxConcurrent) { + const batch = await Promise.all(refs.slice(start, start + maxConcurrent).map(hydrate)) + for (const run of batch) { + const runBytes = hermesRunOutputByteLength(run) + if (retainedBytes + runBytes <= maxRetainedBytes) { + results.push(run) + retainedBytes += runBytes + } else { + results.push(omitHermesRunOutput(run)) + } + } + } + return results +} + +export function formatHermesSessionMessagesWithinLimits( + messages: Iterable>, + limits: HermesSessionTranscriptLimits = {} +): FormattedHermesSessionMessages { + const maxBytes = clampFiniteLimit(limits.maxBytes, 0, HERMES_SESSION_TRANSCRIPT_MAX_BYTES) + const maxMessages = clampFiniteLimit( + limits.maxMessages, + 0, + HERMES_SESSION_TRANSCRIPT_MAX_MESSAGES + ) + const chunks: string[] = [] + let retainedBytes = 0 + let messageCount = 0 + let truncated = false + + transcript: for (const message of messages) { + if (messageCount >= maxMessages) { + truncated = true + break + } + for (const part of formatHermesSessionMessageParts(message, messageCount > 0)) { + const partBytes = Buffer.byteLength(part) + if (retainedBytes + partBytes <= maxBytes) { + chunks.push(part) + retainedBytes += partBytes + continue + } + const prefix = takeUtf8Prefix(part, maxBytes - retainedBytes) + if (prefix) { + chunks.push(prefix) + retainedBytes += Buffer.byteLength(prefix) + } + truncated = true + break transcript + } + messageCount += 1 + if (message[HERMES_SESSION_TRANSCRIPT_SOURCE_TRUNCATED_COLUMN] === 1) { + truncated = true + break + } + } + + if (truncated) { + appendTranscriptTruncationNotice(chunks, retainedBytes, maxBytes) + } + return { content: chunks.length > 0 ? chunks.join('') : null, truncated } +} + +function clampFiniteLimit(value: number | undefined, minimum: number, maximum: number): number { + if (value === undefined || !Number.isFinite(value)) { + return maximum + } + return Math.max(minimum, Math.min(maximum, Math.floor(value))) +} + +function hermesRunOutputByteLength(run: unknown): number { + if (!run || typeof run !== 'object' || Array.isArray(run)) { + return 0 + } + const content = (run as Record).output_content + return typeof content === 'string' ? Buffer.byteLength(content) : 0 +} + +function formatHermesSessionMessageParts( + message: Record, + includeSeparator: boolean +): string[] { + const role = typeof message.role === 'string' ? message.role : 'message' + const content = typeof message.content === 'string' ? message.content.trim() : '' + const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' + const reasoning = + typeof message.reasoning_content === 'string' + ? message.reasoning_content.trim() + : typeof message.reasoning === 'string' + ? message.reasoning.trim() + : '' + const parts = [ + includeSeparator ? '\n\n---\n\n' : '', + `## ${role}${toolName ? ` / ${toolName}` : ''}` + ] + if (reasoning) { + parts.push('\n\n### Reasoning\n\n', reasoning) + } + parts.push('\n\n', content || '(empty)') + return parts +} + +function appendTranscriptTruncationNotice( + chunks: string[], + retainedBytes: number, + maxBytes: number +): void { + const separator = chunks.length > 0 ? '\n\n---\n\n' : '' + const notice = `${separator}${HERMES_SESSION_TRANSCRIPT_TRUNCATION_NOTICE}` + const boundedNotice = takeUtf8Prefix(notice, maxBytes) + const noticeBytes = Buffer.byteLength(boundedNotice) + const targetBytes = maxBytes - noticeBytes + trimUtf8Chunks(chunks, retainedBytes, targetBytes) + if (boundedNotice) { + chunks.push(boundedNotice) + } +} + +function trimUtf8Chunks(chunks: string[], retainedBytes: number, targetBytes: number): void { + let bytes = retainedBytes + while (bytes > targetBytes && chunks.length > 0) { + const lastIndex = chunks.length - 1 + const last = chunks[lastIndex]! + const lastBytes = Buffer.byteLength(last) + const allowedBytes = Math.max(0, lastBytes - (bytes - targetBytes)) + if (allowedBytes === 0) { + chunks.pop() + bytes -= lastBytes + continue + } + const prefix = takeUtf8Prefix(last, allowedBytes) + chunks[lastIndex] = prefix + bytes = bytes - lastBytes + Buffer.byteLength(prefix) + } +} + +function takeUtf8Prefix(value: string, maxBytes: number): string { + if (maxBytes <= 0) { + return '' + } + if (Buffer.byteLength(value) <= maxBytes) { + return value + } + let low = 0 + let high = Math.min(value.length, maxBytes) + while (low < high) { + const middle = Math.ceil((low + high) / 2) + if (Buffer.byteLength(value.slice(0, middle)) <= maxBytes) { + low = middle + } else { + high = middle - 1 + } + } + if (low > 0 && isHighSurrogate(value.charCodeAt(low - 1))) { + low -= 1 + } + return value.slice(0, low) +} + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff +} + +function omitHermesRunOutput(run: unknown): unknown { + if (!run || typeof run !== 'object' || Array.isArray(run)) { + return { output_content: null, error: HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR } + } + const record = run as Record + const existingError = typeof record.error === 'string' && record.error ? record.error : null + return { + ...record, + output_content: null, + error: existingError + ? `${existingError}; ${HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR}` + : HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR + } +} diff --git a/src/shared/hermes-run-ref-retention.test.ts b/src/shared/hermes-run-ref-retention.test.ts new file mode 100644 index 00000000000..669d41bda99 --- /dev/null +++ b/src/shared/hermes-run-ref-retention.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { HermesRunRefRetainer } from './hermes-run-ref-retention' + +describe('HermesRunRefRetainer', () => { + it('preserves every ref and exact order below the limit', () => { + const retainer = new HermesRunRefRetainer<{ id: string; run_at: string }>(3) + retainer.add({ id: 'middle', run_at: '2026-05-15T09:00:00Z' }) + retainer.add({ id: 'newest', run_at: '2026-05-16T09:00:00Z' }) + retainer.add({ id: 'oldest', run_at: '2026-05-14T09:00:00Z' }) + + expect(retainer.finish()).toEqual({ + refs: [ + { id: 'newest', run_at: '2026-05-16T09:00:00Z' }, + { id: 'middle', run_at: '2026-05-15T09:00:00Z' }, + { id: 'oldest', run_at: '2026-05-14T09:00:00Z' } + ], + saturated: false + }) + }) + + it('retains the exact newest window and reports saturation', () => { + const retainer = new HermesRunRefRetainer<{ id: string; run_at: string }>(3) + for (const day of [1, 5, 2, 6, 3, 4]) { + retainer.add({ id: `day-${day}`, run_at: `2026-05-0${day}T09:00:00Z` }) + } + + expect(retainer.finish()).toEqual({ + refs: [ + { id: 'day-6', run_at: '2026-05-06T09:00:00Z' }, + { id: 'day-5', run_at: '2026-05-05T09:00:00Z' }, + { id: 'day-4', run_at: '2026-05-04T09:00:00Z' } + ], + saturated: true + }) + }) +}) diff --git a/src/shared/hermes-run-ref-retention.ts b/src/shared/hermes-run-ref-retention.ts new file mode 100644 index 00000000000..15aaefe3b13 --- /dev/null +++ b/src/shared/hermes-run-ref-retention.ts @@ -0,0 +1,78 @@ +export const HERMES_RUN_REF_MAX_ENTRIES = 10_000 +export const HERMES_RUN_REF_ID_MAX_BYTES = 4 * 1024 + +export const HERMES_SESSION_RUN_REFS_SELECT_SQL = `SELECT + CASE WHEN length(CAST(id AS BLOB)) <= ${HERMES_RUN_REF_ID_MAX_BYTES} THEN id ELSE NULL END AS id, + CASE WHEN typeof(started_at) IN ('integer', 'real') THEN started_at ELSE NULL END AS started_at +FROM sessions +WHERE id LIKE ? ESCAPE '\\' +ORDER BY started_at DESC +LIMIT ${HERMES_RUN_REF_MAX_ENTRIES + 1}` + +export type HermesSortableRunRef = { + id: string + run_at: string | null +} + +export type BoundedHermesRunRefs = { + refs: T[] + saturated: boolean +} + +export class HermesRunRefRetainer { + private readonly retained: T[] = [] + private readonly pending: T[] = [] + private readonly maxEntries: number + private seen = 0 + + constructor(maxEntries = HERMES_RUN_REF_MAX_ENTRIES) { + this.maxEntries = Number.isFinite(maxEntries) + ? Math.max(0, Math.min(HERMES_RUN_REF_MAX_ENTRIES, Math.floor(maxEntries))) + : HERMES_RUN_REF_MAX_ENTRIES + } + + add(ref: T): void { + this.seen += 1 + if (this.maxEntries === 0) { + return + } + this.pending.push(ref) + if (this.pending.length >= this.maxEntries) { + this.flush() + } + } + + finish(): BoundedHermesRunRefs { + this.flush() + return { + refs: this.retained.slice(), + saturated: this.seen > this.maxEntries + } + } + + private flush(): void { + if (this.pending.length === 0) { + return + } + for (const ref of this.pending) { + this.retained.push(ref) + } + this.pending.length = 0 + this.retained.sort(compareHermesRunRefsNewestFirst) + if (this.retained.length > this.maxEntries) { + this.retained.length = this.maxEntries + } + } +} + +export function compareHermesRunRefsNewestFirst( + left: HermesSortableRunRef, + right: HermesSortableRunRef +): number { + const leftTime = left.run_at ? Date.parse(left.run_at) : Number.NaN + const rightTime = right.run_at ? Date.parse(right.run_at) : Number.NaN + if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) { + return rightTime - leftTime + } + return right.id.localeCompare(left.id) +} diff --git a/src/shared/html-to-pdf-memory-limit.ts b/src/shared/html-to-pdf-memory-limit.ts new file mode 100644 index 00000000000..2e195bbd49c --- /dev/null +++ b/src/shared/html-to-pdf-memory-limit.ts @@ -0,0 +1,13 @@ +import { measureUtf8ByteLength } from './memory-safety/utf8-byte-limits' + +export const HTML_TO_PDF_MAX_INPUT_BYTES = 32 * 1024 * 1024 +export const HTML_TO_PDF_MEMORY_LIMIT_ERROR = 'HTML export exceeds the PDF memory limit' + +export function assertHtmlToPdfInputWithinMemoryLimit( + html: string, + maxBytes = HTML_TO_PDF_MAX_INPUT_BYTES +): void { + if (measureUtf8ByteLength(html, { stopAfterBytes: maxBytes }).exceededLimit) { + throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR) + } +} diff --git a/src/shared/linux-proc-port-scan-limits.test.ts b/src/shared/linux-proc-port-scan-limits.test.ts new file mode 100644 index 00000000000..9d382c7a31d --- /dev/null +++ b/src/shared/linux-proc-port-scan-limits.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createLinuxProcTextReadBudget, + LINUX_PROC_NETWORK_TABLE_MAX_BYTES, + readLinuxProcNetworkTable, + readLinuxProcTextWithinBudget +} from './linux-proc-port-scan-limits' + +describe('Linux proc port scan limits', () => { + it('applies the network-table byte cap before retaining content', async () => { + const readFile = vi.fn(async () => Buffer.from('table')) + + await expect(readLinuxProcNetworkTable('/proc/net/tcp', readFile)).resolves.toBe('table') + expect(readFile).toHaveBeenCalledWith('/proc/net/tcp', LINUX_PROC_NETWORK_TABLE_MAX_BYTES) + }) + + it('shares one retained-byte budget across process metadata files', async () => { + const budget = createLinuxProcTextReadBudget(5) + const readFile = vi + .fn<(filePath: string, maxBytes: number) => Promise>() + .mockResolvedValueOnce(Buffer.from('abc')) + .mockResolvedValueOnce(Buffer.from('de')) + + await expect(readLinuxProcTextWithinBudget('/proc/1/comm', budget, readFile, 4)).resolves.toBe( + 'abc' + ) + await expect( + readLinuxProcTextWithinBudget('/proc/1/cmdline', budget, readFile, 4) + ).resolves.toBe('de') + await expect( + readLinuxProcTextWithinBudget('/proc/2/cmdline', budget, readFile, 4) + ).resolves.toBeUndefined() + + expect(readFile.mock.calls).toEqual([ + ['/proc/1/comm', 4], + ['/proc/1/cmdline', 2] + ]) + expect(budget.remainingBytes).toBe(0) + }) + + it('does not debit failed metadata reads', async () => { + const budget = createLinuxProcTextReadBudget(4) + const readFile = vi.fn(async () => { + throw new Error('oversized') + }) + + await expect( + readLinuxProcTextWithinBudget('/proc/1/cmdline', budget, readFile) + ).resolves.toBeUndefined() + expect(budget.remainingBytes).toBe(4) + }) +}) diff --git a/src/shared/linux-proc-port-scan-limits.ts b/src/shared/linux-proc-port-scan-limits.ts new file mode 100644 index 00000000000..7b4a0fdfb53 --- /dev/null +++ b/src/shared/linux-proc-port-scan-limits.ts @@ -0,0 +1,55 @@ +import { readNodeFileWithinLimit } from './memory-safety/node-bounded-file-reader' + +export const LINUX_PROC_NETWORK_TABLE_MAX_BYTES = 8 * 1024 * 1024 +export const LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES = 2_048 +export const LINUX_PROC_PROCESS_METADATA_MAX_BYTES = 8 * 1024 * 1024 +export const LINUX_PROC_PROCESS_METADATA_FILE_MAX_BYTES = 64 * 1024 + +export type LinuxProcTextReadBudget = { remainingBytes: number } + +type LinuxProcTextReader = (filePath: string, maxBytes: number) => Promise + +const readBoundedNodeFile: LinuxProcTextReader = async (filePath, maxBytes) => + (await readNodeFileWithinLimit(filePath, maxBytes)).buffer + +export function createLinuxProcTextReadBudget( + maxBytes = LINUX_PROC_PROCESS_METADATA_MAX_BYTES +): LinuxProcTextReadBudget { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + throw new RangeError('Linux proc text budget must be a non-negative safe integer') + } + return { remainingBytes: maxBytes } +} + +export async function readLinuxProcNetworkTable( + filePath: string, + readFile: LinuxProcTextReader = readBoundedNodeFile +): Promise { + try { + return (await readFile(filePath, LINUX_PROC_NETWORK_TABLE_MAX_BYTES)).toString('utf8') + } catch { + return null + } +} + +export async function readLinuxProcTextWithinBudget( + filePath: string, + budget: LinuxProcTextReadBudget, + readFile: LinuxProcTextReader = readBoundedNodeFile, + perFileMaxBytes = LINUX_PROC_PROCESS_METADATA_FILE_MAX_BYTES +): Promise { + const maxBytes = Math.min(perFileMaxBytes, budget.remainingBytes) + if (maxBytes <= 0) { + return undefined + } + try { + const content = await readFile(filePath, maxBytes) + if (content.byteLength > maxBytes) { + return undefined + } + budget.remainingBytes -= content.byteLength + return content.toString('utf8') + } catch { + return undefined + } +} diff --git a/src/shared/mobile-file-directory-limit.test.ts b/src/shared/mobile-file-directory-limit.test.ts new file mode 100644 index 00000000000..2d2228c2af7 --- /dev/null +++ b/src/shared/mobile-file-directory-limit.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + assertMobileFileDirectoryWithinLimit, + MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE, + MOBILE_FILE_DIRECTORY_MAX_ENTRIES, + MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES +} from './mobile-file-directory-limit' + +describe('mobile file directory limit', () => { + it('accepts a complete directory listing within both limits', () => { + expect(() => + assertMobileFileDirectoryWithinLimit([ + { name: 'src' }, + { name: 'README.md' }, + { name: 'package.json' } + ]) + ).not.toThrow() + }) + + it('rejects rather than truncating an excessive entry count', () => { + const entries = Array.from({ length: MOBILE_FILE_DIRECTORY_MAX_ENTRIES + 1 }, () => ({ + name: 'x' + })) + + expect(() => assertMobileFileDirectoryWithinLimit(entries)).toThrow( + MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE + ) + }) + + it('rejects a listing whose names exceed the retained-byte limit', () => { + const name = 'x'.repeat(MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES / 2) + + expect(() => assertMobileFileDirectoryWithinLimit([{ name }])).toThrow( + MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE + ) + }) +}) diff --git a/src/shared/mobile-file-directory-limit.ts b/src/shared/mobile-file-directory-limit.ts new file mode 100644 index 00000000000..9860f078c8d --- /dev/null +++ b/src/shared/mobile-file-directory-limit.ts @@ -0,0 +1,43 @@ +// Why: normal repositories stay complete while pathological fan-out/name payloads fail before retention. +export const MOBILE_FILE_DIRECTORY_MAX_ENTRIES = 10_000 +export const MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES = 4 * 1024 * 1024 +export const MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE = + 'This folder is too large to show safely on mobile (limit: 10,000 items or a 4 MB listing).' + +type NamedDirectoryEntry = { name: string } + +export type MobileFileDirectoryLimitState = { + entries: number + retainedBytes: number +} + +export function createMobileFileDirectoryLimitState(): MobileFileDirectoryLimitState { + return { entries: 0, retainedBytes: 0 } +} + +export function trackMobileFileDirectoryEntry( + state: MobileFileDirectoryLimitState, + entry: NamedDirectoryEntry +): void { + state.entries += 1 + state.retainedBytes += estimateMobileDirectoryEntryBytes(entry) + if ( + state.entries > MOBILE_FILE_DIRECTORY_MAX_ENTRIES || + state.retainedBytes > MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES + ) { + throw new Error(MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE) + } +} + +export function assertMobileFileDirectoryWithinLimit( + entries: readonly NamedDirectoryEntry[] +): void { + const state = createMobileFileDirectoryLimitState() + for (const entry of entries) { + trackMobileFileDirectoryEntry(state, entry) + } +} + +export function estimateMobileDirectoryEntryBytes(entry: NamedDirectoryEntry): number { + return entry.name.length * 2 + 64 +} diff --git a/src/shared/osc52-clipboard-settings.test.ts b/src/shared/osc52-clipboard-settings.test.ts new file mode 100644 index 00000000000..e4a79df5a6b --- /dev/null +++ b/src/shared/osc52-clipboard-settings.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from './osc52-clipboard-settings' + +describe('normalizeOsc52ClipboardDefaultOn', () => { + it('flips an unstamped profile on, because its `false` came from the old default', () => { + // Why: every profile saved before #10567 persisted `false`, which the settings + // merge then wins with — indistinguishable on disk from a deliberate opt-out. + expect(normalizeOsc52ClipboardDefaultOn({ terminalAllowOsc52Clipboard: false })).toEqual({ + terminalAllowOsc52Clipboard: true, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + }) + + it('leaves a stamped opt-out off', () => { + expect( + normalizeOsc52ClipboardDefaultOn({ + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + ).toEqual({ + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + }) + + it('defaults a fresh or absent profile on', () => { + for (const settings of [ + undefined, + {}, + { terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true } + ]) { + expect(normalizeOsc52ClipboardDefaultOn(settings)).toEqual({ + terminalAllowOsc52Clipboard: true, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + } + }) + + it('is idempotent, so a crash before the write-back cannot re-flip an opt-out', () => { + const once = normalizeOsc52ClipboardDefaultOn({ terminalAllowOsc52Clipboard: false }) + expect(normalizeOsc52ClipboardDefaultOn(once)).toEqual(once) + + const optedOut = normalizeOsc52ClipboardDefaultOn({ + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + expect(normalizeOsc52ClipboardDefaultOn(optedOut)).toEqual(optedOut) + }) +}) + +describe('osc52ClipboardDefaultOnOverridesPersistedOff', () => { + it('is true only when the migration overrides a persisted off', () => { + expect( + osc52ClipboardDefaultOnOverridesPersistedOff({ terminalAllowOsc52Clipboard: false }) + ).toBe(true) + }) + + it('is false for a fresh profile, which has no persisted value to override', () => { + // Why: a notice here would nag every new install about a setting they never touched. + expect(osc52ClipboardDefaultOnOverridesPersistedOff(undefined)).toBe(false) + expect(osc52ClipboardDefaultOnOverridesPersistedOff({})).toBe(false) + expect( + osc52ClipboardDefaultOnOverridesPersistedOff({ terminalAllowOsc52Clipboard: true }) + ).toBe(false) + }) + + it('is false once stamped, because a stamped value is honored rather than overridden', () => { + expect( + osc52ClipboardDefaultOnOverridesPersistedOff({ + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + ).toBe(false) + }) +}) diff --git a/src/shared/osc52-clipboard-settings.ts b/src/shared/osc52-clipboard-settings.ts new file mode 100644 index 00000000000..2beb06dda16 --- /dev/null +++ b/src/shared/osc52-clipboard-settings.ts @@ -0,0 +1,37 @@ +import type { GlobalSettings } from './types' + +type Osc52ClipboardSettings = Pick< + GlobalSettings, + 'terminalAllowOsc52Clipboard' | 'terminalAllowOsc52ClipboardDefaultedOnForAllUsers' +> + +export function normalizeOsc52ClipboardDefaultOn( + settings: Partial | undefined +): Osc52ClipboardSettings { + const defaultedOn = settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers === true + + return { + // Why: profiles saved under the old off default persisted `false`, which is + // indistinguishable from a real opt-out; only stamped profiles can express one. + terminalAllowOsc52Clipboard: defaultedOn + ? (settings?.terminalAllowOsc52Clipboard ?? true) + : true, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + } +} + +/** True when the migration is about to turn OSC 52 on over a persisted `false`. + * Why surface it: flipping a security posture back on without telling anyone is worse + * than the copy bug. Scope is deliberately wide — both stores rewrite the whole settings + * object on every save, so every profile saved under the old off default holds `false`, + * opt-out or not. This is an upgrade notice, not an opt-out notice; the cohort that + * genuinely chose `false` is not distinguishable on disk. Fresh profiles have no + * persisted value and are correctly excluded. */ +export function osc52ClipboardDefaultOnOverridesPersistedOff( + settings: Partial | undefined +): boolean { + return ( + settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true && + settings?.terminalAllowOsc52Clipboard === false + ) +} diff --git a/src/shared/persisted-state-file-bounds.test.ts b/src/shared/persisted-state-file-bounds.test.ts new file mode 100644 index 00000000000..6dd997a4321 --- /dev/null +++ b/src/shared/persisted-state-file-bounds.test.ts @@ -0,0 +1,171 @@ +import { createHash } from 'node:crypto' +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + truncateSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { NodeFileReadTooLargeError } from './memory-safety/node-bounded-file-reader' +import { JsonStringifyByteLimitError } from './memory-safety/node-bounded-json-stringify' +import { + PersistedStateSecretCapacityError, + assertPersistedStateSecretWithinLimit, + readPersistedStateJsonFileSync, + replacePersistedStateJsonWithinLimit, + restorePersistedStateBackupSync, + stringifyPrettyPersistedStateWithinLimit, + stringifyPersistedStateWithinLimit, + updatePersistedStateHashWithJsonRange +} from './persisted-state-file-bounds' + +describe('persisted state file bounds', () => { + let root = '' + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-state-bounds-')) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('reads and parses a state file exactly at the byte limit', () => { + const path = join(root, 'state.json') + const json = `{"value":"${'x'.repeat(20)}"}` + writeFileSync(path, json) + + expect( + readPersistedStateJsonFileSync<{ value: string }>(path, Buffer.byteLength(json)) + ).toEqual({ + byteLength: Buffer.byteLength(json), + value: { value: 'x'.repeat(20) } + }) + }) + + it('rejects an oversized sparse state file before reading its body', () => { + const path = join(root, 'state.json') + writeFileSync(path, '') + truncateSync(path, 1025) + + expect(() => readPersistedStateJsonFileSync(path, 1024)).toThrow(NodeFileReadTooLargeError) + }) + + it('rejects structurally amplified state before parsing it', () => { + const path = join(root, 'state.json') + const json = '{"rows":[{},{}]}' + writeFileSync(path, json) + + expect(() => + readPersistedStateJsonFileSync(path, Buffer.byteLength(json), { + structuralTokens: 7, + nestingDepth: 3 + }) + ).toThrow('JSON structure') + }) + + it('matches native compact JSON exactly at the output boundary', () => { + const state = { quote: '"', unicode: '🐋', nested: [1, true, null] } + const native = JSON.stringify(state) + + expect(stringifyPersistedStateWithinLimit(state, Buffer.byteLength(native))).toEqual({ + byteLength: Buffer.byteLength(native), + serialized: native + }) + expect(() => stringifyPersistedStateWithinLimit(state, Buffer.byteLength(native) - 1)).toThrow( + JsonStringifyByteLimitError + ) + }) + + it('matches native pretty JSON and enforces its whitespace-inclusive boundary', () => { + const state = { nested: { value: 'x' }, list: [1, 2] } + const native = JSON.stringify(state, null, 2) + + expect(stringifyPrettyPersistedStateWithinLimit(state, Buffer.byteLength(native))).toEqual({ + byteLength: Buffer.byteLength(native), + serialized: native + }) + expect(() => + stringifyPrettyPersistedStateWithinLimit(state, Buffer.byteLength(native) - 1) + ).toThrow(JsonStringifyByteLimitError) + }) + + it('bounds secret plaintext before encryption can expand it', () => { + assertPersistedStateSecretWithinLimit('🐋', 4) + + expect(() => assertPersistedStateSecretWithinLimit('🐋x', 4)).toThrow( + PersistedStateSecretCapacityError + ) + }) + + it('checks replacement growth before constructing the next payload', () => { + const serialized = '{"value":"slot"}' + const exactBytes = + Buffer.byteLength(serialized) - Buffer.byteLength('slot') + Buffer.byteLength('expanded') + + expect( + replacePersistedStateJsonWithinLimit({ + serialized, + currentBytes: Buffer.byteLength(serialized), + search: 'slot', + replacement: 'expanded', + maxBytes: exactBytes + }) + ).toEqual({ byteLength: exactBytes, serialized: '{"value":"expanded"}' }) + expect(() => + replacePersistedStateJsonWithinLimit({ + serialized, + currentBytes: Buffer.byteLength(serialized), + search: 'slot', + replacement: 'expanded', + maxBytes: exactBytes - 1 + }) + ).toThrow(JsonStringifyByteLimitError) + }) + + it('hashes bounded string ranges without splitting UTF-16 surrogate pairs', () => { + const value = `prefix-${'x'.repeat(8)}🐋-${'y'.repeat(8)}-suffix` + const expected = createHash('sha1').update(value).digest('hex') + const actual = createHash('sha1') + + updatePersistedStateHashWithJsonRange(actual, value, 0, value.length, 2) + + expect(actual.digest('hex')).toBe(expected) + }) + + it('atomically restores only a valid in-limit backup', () => { + const backupPath = join(root, 'backup.json') + const targetPath = join(root, 'profile', 'orca-data.json') + writeFileSync(backupPath, '{"repos":[{"id":"recovered"}]}') + + restorePersistedStateBackupSync(backupPath, targetPath, 1024) + + expect(JSON.parse(readFileSync(targetPath, 'utf8'))).toEqual({ + repos: [{ id: 'recovered' }] + }) + const originalTarget = readFileSync(targetPath) + writeFileSync(backupPath, '{{invalid') + expect(() => restorePersistedStateBackupSync(backupPath, targetPath, 1024)).toThrow() + expect(readFileSync(targetPath)).toEqual(originalTarget) + expect( + readdirSync(join(root, 'profile')).filter((name) => name.endsWith('.recovery.tmp')) + ).toEqual([]) + }) + + it('leaves the target untouched when a backup exceeds the cap', () => { + const backupPath = join(root, 'backup.json') + const targetPath = join(root, 'orca-data.json') + writeFileSync(targetPath, '{"original":true}') + writeFileSync(backupPath, '') + truncateSync(backupPath, 1025) + + expect(() => restorePersistedStateBackupSync(backupPath, targetPath, 1024)).toThrow( + NodeFileReadTooLargeError + ) + expect(readFileSync(targetPath, 'utf8')).toBe('{"original":true}') + }) +}) diff --git a/src/shared/persisted-state-file-bounds.ts b/src/shared/persisted-state-file-bounds.ts new file mode 100644 index 00000000000..f11a38a1633 --- /dev/null +++ b/src/shared/persisted-state-file-bounds.ts @@ -0,0 +1,222 @@ +import { randomUUID, type Hash } from 'node:crypto' +import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { + NodeFileReadTooLargeError, + readNodeFileSyncWithinLimit, + type BoundedNodeFileRead +} from './memory-safety/node-bounded-file-reader' +import { + JsonStringifyByteLimitError, + stringifyJsonWithinByteLimit +} from './memory-safety/node-bounded-json-stringify' +import { + assertJsonTextStructureWithinLimits, + type JsonTextStructureLimits +} from './memory-safety/json-text-structure-limit' + +export const ORCA_PERSISTED_STATE_MAX_BYTES = 64 * 1024 * 1024 +export const ORCA_PERSISTED_STATE_SECRET_MAX_BYTES = 4 * 1024 * 1024 +export const ORCA_PERSISTED_STATE_HASH_CHUNK_CODE_UNITS = 64 * 1024 +export const ORCA_PERSISTED_STATE_JSON_LIMITS: JsonTextStructureLimits = { + structuralTokens: 4_000_000, + nestingDepth: 256 +} + +export type PersistedStateJsonRead = { + byteLength: number + value: T +} + +export class PersistedStateSecretCapacityError extends Error { + constructor( + readonly observedBytes: number, + readonly maxBytes = ORCA_PERSISTED_STATE_SECRET_MAX_BYTES + ) { + super(`Persisted state secret exceeds ${maxBytes} bytes`) + this.name = 'PersistedStateSecretCapacityError' + } +} + +export function isPersistedStateFileCapacityError( + error: unknown +): error is NodeFileReadTooLargeError { + return error instanceof NodeFileReadTooLargeError +} + +export function readPersistedStateJsonFileSync( + filePath: string, + maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES, + structureLimits: JsonTextStructureLimits = ORCA_PERSISTED_STATE_JSON_LIMITS +): PersistedStateJsonRead { + const { buffer } = readPersistedStateFileBytesSync(filePath, maxBytes) + return { + byteLength: buffer.byteLength, + value: parsePersistedStateJsonBuffer(buffer, structureLimits) + } +} + +export function readPersistedStateFileBytesSync( + filePath: string, + maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES +): BoundedNodeFileRead { + return readNodeFileSyncWithinLimit(filePath, maxBytes) +} + +export function parsePersistedStateJsonBuffer( + buffer: Buffer, + structureLimits: JsonTextStructureLimits = ORCA_PERSISTED_STATE_JSON_LIMITS +): T { + const serialized = buffer.toString('utf8') + assertJsonTextStructureWithinLimits(serialized, structureLimits) + return JSON.parse(serialized) as T +} + +export function stringifyPersistedStateWithinLimit( + value: unknown, + maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES +): { byteLength: number; serialized: string } { + return stringifyJsonWithinByteLimit(value, maxBytes) +} + +export function stringifyPrettyPersistedStateWithinLimit( + value: unknown, + maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES +): { byteLength: number; serialized: string } { + return stringifyJsonWithinByteLimit(value, maxBytes, 2) +} + +export function encodePersistedStateJsonStringContent( + value: string, + maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES +): string { + const { serialized } = stringifyJsonWithinByteLimit(value, maxBytes) + return serialized.slice(1, -1) +} + +export function assertPersistedStateSecretWithinLimit( + value: string, + maxBytes = ORCA_PERSISTED_STATE_SECRET_MAX_BYTES +): void { + const observedBytes = Buffer.byteLength(value, 'utf8') + if (observedBytes > maxBytes) { + throw new PersistedStateSecretCapacityError(observedBytes, maxBytes) + } +} + +export function replacedPersistedStateJsonByteLength(options: { + currentBytes: number + maxBytes?: number + replacement: string + search: string +}): number { + const maxBytes = options.maxBytes ?? ORCA_PERSISTED_STATE_MAX_BYTES + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + throw new RangeError('Persisted state JSON byte limit must be a non-negative safe integer') + } + if (!Number.isSafeInteger(options.currentBytes) || options.currentBytes < 0) { + throw new RangeError('Persisted state JSON byte count must be a non-negative safe integer') + } + if (options.currentBytes > maxBytes) { + throw new JsonStringifyByteLimitError(options.currentBytes, maxBytes) + } + const nextBytes = + options.currentBytes - + Buffer.byteLength(options.search, 'utf8') + + Buffer.byteLength(options.replacement, 'utf8') + if (!Number.isSafeInteger(nextBytes) || nextBytes < 0 || nextBytes > maxBytes) { + throw new JsonStringifyByteLimitError(nextBytes, maxBytes) + } + return nextBytes +} + +export function replacePersistedStateJsonWithinLimit(options: { + currentBytes: number + maxBytes?: number + replacement: string + search: string + serialized: string +}): { byteLength: number; serialized: string } { + const byteLength = replacedPersistedStateJsonByteLength(options) + const searchIndex = options.serialized.indexOf(options.search) + if (searchIndex === -1) { + throw new Error('Persisted state JSON replacement slot is missing') + } + if (options.serialized.includes(options.search, searchIndex + options.search.length)) { + throw new Error('Persisted state JSON replacement slot is ambiguous') + } + return { + byteLength, + serialized: options.serialized.replace(options.search, () => options.replacement) + } +} + +export function updatePersistedStateHashWithJsonRange( + hash: Pick, + value: string, + start = 0, + end = value.length, + chunkCodeUnits = ORCA_PERSISTED_STATE_HASH_CHUNK_CODE_UNITS +): void { + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end < start || + end > value.length + ) { + throw new RangeError('Persisted state hash range is invalid') + } + if (!Number.isSafeInteger(chunkCodeUnits) || chunkCodeUnits <= 0) { + throw new RangeError('Persisted state hash chunk size must be a positive safe integer') + } + + let offset = start + while (offset < end) { + let nextOffset = Math.min(end, offset + chunkCodeUnits) + if ( + nextOffset < end && + isHighSurrogate(value.charCodeAt(nextOffset - 1)) && + isLowSurrogate(value.charCodeAt(nextOffset)) + ) { + nextOffset += 1 + } + hash.update(value.slice(offset, nextOffset), 'utf8') + offset = nextOffset + } +} + +export function restorePersistedStateBackupSync( + sourcePath: string, + targetPath: string, + maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES +): number { + const read = readValidatedPersistedStateBytesSync(sourcePath, maxBytes) + mkdirSync(dirname(targetPath), { recursive: true }) + const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.recovery.tmp` + try { + writeFileSync(temporaryPath, read.buffer) + renameSync(temporaryPath, targetPath) + } catch (error) { + rmSync(temporaryPath, { force: true }) + throw error + } + return read.buffer.byteLength +} + +function readValidatedPersistedStateBytesSync( + filePath: string, + maxBytes: number +): BoundedNodeFileRead { + const read = readPersistedStateFileBytesSync(filePath, maxBytes) + parsePersistedStateJsonBuffer(read.buffer) + return read +} + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff +} + +function isLowSurrogate(code: number): boolean { + return code >= 0xdc00 && code <= 0xdfff +} diff --git a/src/shared/pr-refresh-memory-limits.ts b/src/shared/pr-refresh-memory-limits.ts new file mode 100644 index 00000000000..8b695adede7 --- /dev/null +++ b/src/shared/pr-refresh-memory-limits.ts @@ -0,0 +1,5 @@ +export const PR_REFRESH_QUEUE_ENTRY_LIMIT = 1_024 +export const PR_REFRESH_ALIAS_LIMIT = 64 +export const PR_REFRESH_VISIBLE_CANDIDATE_LIMIT = 1_024 +export const PR_REFRESH_RETRY_STATE_LIMIT = 1_024 +export const PR_REFRESH_ACTIVE_SCOPE_LIMIT = 1_024 diff --git a/src/shared/pull-request-generation.ts b/src/shared/pull-request-generation.ts index bc3fab88856..2c063b703bb 100644 --- a/src/shared/pull-request-generation.ts +++ b/src/shared/pull-request-generation.ts @@ -16,6 +16,8 @@ export type PullRequestDraftContext = { commitSummary: string changeSummary: string patch: string + /** Workspace-linked GitHub issue number. Omitted entirely when none resolves. */ + linkedIssue?: number | null } export type GeneratedPullRequestFields = { diff --git a/src/shared/quick-open-listing-limits.test.ts b/src/shared/quick-open-listing-limits.test.ts new file mode 100644 index 00000000000..bd24c80da6c --- /dev/null +++ b/src/shared/quick-open-listing-limits.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createQuickOpenListingBudget, + QuickOpenSubprocessPathAccumulator, + resolveQuickOpenResultLimit, + retainQuickOpenPath, + QUICK_OPEN_LISTING_MAX_PATH_BYTES, + QUICK_OPEN_LISTING_MAX_RETAINED_PATH_BYTES, + QUICK_OPEN_LISTING_MAX_RETAINED_PATHS, + QUICK_OPEN_LISTING_MAX_RESULTS +} from './quick-open-listing-limits' + +describe('Quick Open listing limits', () => { + it('uses one hard result cap while preserving smaller requested limits', () => { + expect(resolveQuickOpenResultLimit()).toBe(QUICK_OPEN_LISTING_MAX_RESULTS) + expect(resolveQuickOpenResultLimit(17)).toBe(17) + expect(resolveQuickOpenResultLimit(QUICK_OPEN_LISTING_MAX_RESULTS + 1)).toBe( + QUICK_OPEN_LISTING_MAX_RESULTS + ) + expect(resolveQuickOpenResultLimit(0)).toBe(0) + }) + + it('keeps the production memory ceilings explicit', () => { + expect(QUICK_OPEN_LISTING_MAX_RESULTS).toBe(20_001) + expect(QUICK_OPEN_LISTING_MAX_RETAINED_PATHS).toBe(100_000) + expect(QUICK_OPEN_LISTING_MAX_RETAINED_PATH_BYTES).toBe(32 * 1024 * 1024) + expect(QUICK_OPEN_LISTING_MAX_PATH_BYTES).toBe(64 * 1024) + }) + + it('accepts the exact retained path boundaries without charging duplicates', () => { + const paths = new Set() + const budget = createQuickOpenListingBudget({ + maxRetainedPaths: 2, + maxRetainedPathBytes: 3, + maxPathBytes: 2 + }) + + expect(retainQuickOpenPath(paths, 'ab', budget)).toBe(true) + expect(retainQuickOpenPath(paths, 'ab', budget)).toBe(false) + expect(retainQuickOpenPath(paths, 'c', budget)).toBe(true) + expect(budget).toMatchObject({ retainedPathCount: 2, retainedPathBytes: 3 }) + expect(() => retainQuickOpenPath(paths, '', budget)).toThrow('2 retained paths') + }) + + it('rejects path-byte overflow without mutating the retained budget', () => { + const paths = new Set() + const budget = createQuickOpenListingBudget({ + maxRetainedPaths: 3, + maxRetainedPathBytes: 2, + maxPathBytes: 2 + }) + retainQuickOpenPath(paths, 'ab', budget) + + expect(() => retainQuickOpenPath(paths, 'c', budget)).toThrow('2 retained path bytes') + expect(paths).toEqual(new Set(['ab'])) + expect(budget).toMatchObject({ retainedPathCount: 1, retainedPathBytes: 2 }) + }) + + it('bounds one fragmented subprocess path and recovers after overflow', () => { + const onPath = vi.fn(() => true) + const fields = new QuickOpenSubprocessPathAccumulator(0, 3) + + expect(fields.push(Buffer.from('ab'), onPath)).toBe('continue') + expect(fields.push(Buffer.from('cd'), onPath)).toBe('path-too-large') + expect(fields.push(Buffer.from('ok\0'), onPath)).toBe('continue') + expect(onPath).toHaveBeenCalledTimes(1) + expect(onPath).toHaveBeenCalledWith('ok') + }) + + it('stops within a multi-path chunk without visiting later fields', () => { + const visited: string[] = [] + const fields = new QuickOpenSubprocessPathAccumulator(0, 16) + + expect( + fields.push(Buffer.from('one\0two\0three\0'), (path) => { + visited.push(path) + return path !== 'two' + }) + ).toBe('stopped') + expect(visited).toEqual(['one', 'two']) + }) +}) diff --git a/src/shared/quick-open-listing-limits.ts b/src/shared/quick-open-listing-limits.ts new file mode 100644 index 00000000000..0e28f8b1d3a --- /dev/null +++ b/src/shared/quick-open-listing-limits.ts @@ -0,0 +1,142 @@ +import { GrowingByteBuffer } from './growing-byte-buffer' + +export const QUICK_OPEN_LISTING_MAX_RESULTS = 20_001 +export const QUICK_OPEN_LISTING_MAX_RETAINED_PATHS = 100_000 +export const QUICK_OPEN_LISTING_MAX_RETAINED_PATH_BYTES = 32 * 1024 * 1024 +export const QUICK_OPEN_LISTING_MAX_PATH_BYTES = 64 * 1024 + +export type QuickOpenListingBudget = { + retainedPathCount: number + retainedPathBytes: number + maxRetainedPaths: number + maxRetainedPathBytes: number + maxPathBytes: number +} + +export function resolveQuickOpenResultLimit(requested?: number): number { + if (requested === undefined || requested === Number.POSITIVE_INFINITY) { + return QUICK_OPEN_LISTING_MAX_RESULTS + } + if (!Number.isFinite(requested)) { + return 0 + } + return Math.min(Math.max(Math.trunc(requested), 0), QUICK_OPEN_LISTING_MAX_RESULTS) +} + +export function createQuickOpenListingBudget( + limits: Partial< + Pick + > = {} +): QuickOpenListingBudget { + const maxRetainedPaths = limits.maxRetainedPaths ?? QUICK_OPEN_LISTING_MAX_RETAINED_PATHS + const maxRetainedPathBytes = + limits.maxRetainedPathBytes ?? QUICK_OPEN_LISTING_MAX_RETAINED_PATH_BYTES + const maxPathBytes = limits.maxPathBytes ?? QUICK_OPEN_LISTING_MAX_PATH_BYTES + for (const [name, value] of Object.entries({ + maxRetainedPaths, + maxRetainedPathBytes, + maxPathBytes + })) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + } + return { + retainedPathCount: 0, + retainedPathBytes: 0, + maxRetainedPaths, + maxRetainedPathBytes, + maxPathBytes + } +} + +export function retainQuickOpenPath( + paths: Set, + path: string, + budget: QuickOpenListingBudget +): boolean { + if (paths.has(path)) { + return false + } + const pathBytes = Buffer.byteLength(path, 'utf8') + if (pathBytes > budget.maxPathBytes) { + throw new Error(`Quick Open file path exceeded ${budget.maxPathBytes} bytes`) + } + if (budget.retainedPathCount >= budget.maxRetainedPaths) { + throw new Error(`Quick Open file listing exceeded ${budget.maxRetainedPaths} retained paths`) + } + if (pathBytes > budget.maxRetainedPathBytes - budget.retainedPathBytes) { + throw new Error( + `Quick Open file listing exceeded ${budget.maxRetainedPathBytes} retained path bytes` + ) + } + budget.retainedPathCount++ + budget.retainedPathBytes += pathBytes + paths.add(path) + return true +} + +export type QuickOpenPathAccumulatorResult = 'continue' | 'stopped' | 'path-too-large' + +export class QuickOpenSubprocessPathAccumulator { + private readonly field = new GrowingByteBuffer() + + constructor( + private readonly delimiter: number, + private readonly maxPathBytes = QUICK_OPEN_LISTING_MAX_PATH_BYTES + ) { + if (!Number.isInteger(delimiter) || delimiter < 0 || delimiter > 0xff) { + throw new RangeError('Quick Open path delimiter must be one byte') + } + if (!Number.isSafeInteger(maxPathBytes) || maxPathBytes < 0) { + throw new RangeError('Quick Open path limit must be a non-negative safe integer') + } + } + + push( + rawChunk: Buffer | string, + onPath: (path: string) => boolean + ): QuickOpenPathAccumulatorResult { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk, 'utf8') + let cursor = 0 + while (cursor < chunk.length) { + const delimiter = chunk.indexOf(this.delimiter, cursor) + const end = delimiter === -1 ? chunk.length : delimiter + const segmentBytes = end - cursor + if (this.field.byteLength + segmentBytes > this.maxPathBytes) { + this.clear() + return 'path-too-large' + } + if (delimiter !== -1 && this.field.byteLength === 0) { + if (!onPath(chunk.toString('utf8', cursor, end))) { + return 'stopped' + } + } else if (segmentBytes > 0) { + // Why: copying prevents a short residual path from retaining the whole read buffer. + this.field.append(chunk.subarray(cursor, end)) + if (delimiter !== -1 && !onPath(this.take())) { + return 'stopped' + } + } else if (delimiter !== -1 && !onPath(this.take())) { + return 'stopped' + } + if (delimiter === -1) { + return 'continue' + } + cursor = delimiter + 1 + } + return 'continue' + } + + finish(): string | null { + return this.field.byteLength > 0 ? this.take() : null + } + + clear(): void { + this.field.clear() + } + + private take(): string { + return this.field.takeString() + } +} diff --git a/src/shared/relay-json-admission.ts b/src/shared/relay-json-admission.ts new file mode 100644 index 00000000000..e7bb1b0a450 --- /dev/null +++ b/src/shared/relay-json-admission.ts @@ -0,0 +1,12 @@ +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' + +export const RELAY_JSON_MAX_STRUCTURAL_TOKENS = 1_000_000 +export const RELAY_JSON_MAX_NESTING_DEPTH = 128 + +export function parseRelayJsonText(text: string): T { + assertJsonTextStructureWithinLimits(text, { + structuralTokens: RELAY_JSON_MAX_STRUCTURAL_TOKENS, + nestingDepth: RELAY_JSON_MAX_NESTING_DEPTH + }) + return JSON.parse(text) as T +} diff --git a/src/shared/renderer-heap-statistics.ts b/src/shared/renderer-heap-statistics.ts new file mode 100644 index 00000000000..4dfc5fbcd07 --- /dev/null +++ b/src/shared/renderer-heap-statistics.ts @@ -0,0 +1,24 @@ +/** + * V8 heap statistics read from the renderer's own process. + * + * Why this exists rather than `window.performance.memory`: Blink quantizes that + * API onto ~100 logarithmic buckets and caches each reading for ~20 minutes as a + * Spectre mitigation. Measured here: a renderer climbing 1MB -> 93MB reported an + * identical `usedJSHeapSize` on all 7 samples, so heap growth is invisible to it + * at any sampling rate. `process.getHeapStatistics()` is exact and uncached, and + * works in a sandboxed, context-isolated preload. + * + * Electron reports these in kilobytes; we keep that unit unconverted here. + */ +export type RendererHeapStatistics = { + usedHeapKB: number + totalHeapKB: number + heapLimitKB: number + /** Off-heap V8 allocations, which `usedJSHeapSize` never included. */ + mallocedKB: number + /** + * Blink's own allocator (DOM, layout), invisible to the V8 heap counters. + * Optional: supplementary, so its absence must never discard the V8 numbers. + */ + blinkAllocatedKB?: number +} diff --git a/src/shared/source-control-ai-action-variables.test.ts b/src/shared/source-control-ai-action-variables.test.ts new file mode 100644 index 00000000000..94cf5be427c --- /dev/null +++ b/src/shared/source-control-ai-action-variables.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import type { CommitMessageDraftContext } from './commit-message-generation' +import { + formatLinkedIssueTemplateValue, + SOURCE_CONTROL_ACTION_VARIABLE_INFO, + SOURCE_CONTROL_ACTION_VARIABLES, + withLinkedIssueDraftContext +} from './source-control-ai-action-variables' +import { + renderSourceControlActionCommandTemplate, + SOURCE_CONTROL_LAUNCH_ACTION_IDS +} from './source-control-ai-actions' + +describe('source-control AI variable registry', () => { + it('documents every registered variable so chip hover cards cannot crash', () => { + // Why: the registry type already makes an undocumented chip a compile error; + // this keeps the guarantee falsifiable at runtime if that type ever loosens. + const documented = new Set(Object.keys(SOURCE_CONTROL_ACTION_VARIABLE_INFO)) + const undocumented = [...new Set(Object.values(SOURCE_CONTROL_ACTION_VARIABLES).flat())].filter( + (variable) => !documented.has(variable) + ) + + expect(undocumented).toEqual([]) + }) + + it('offers linkedIssue on commit message and pull request only', () => { + expect(SOURCE_CONTROL_ACTION_VARIABLES.commitMessage).toContain('linkedIssue') + expect(SOURCE_CONTROL_ACTION_VARIABLES.pullRequest).toContain('linkedIssue') + expect(SOURCE_CONTROL_ACTION_VARIABLES.branchName).not.toContain('linkedIssue') + for (const actionId of SOURCE_CONTROL_LAUNCH_ACTION_IDS) { + expect(SOURCE_CONTROL_ACTION_VARIABLES[actionId]).not.toContain('linkedIssue') + } + }) + + it('names GitHub and the empty case in the linkedIssue description', () => { + const info = SOURCE_CONTROL_ACTION_VARIABLE_INFO.linkedIssue + expect(info.description).toContain('GitHub') + expect(info.description).toContain('Empty') + expect(info.example).toBe('123') + }) +}) + +describe('formatLinkedIssueTemplateValue', () => { + it('renders positive integers as decimal strings', () => { + expect(formatLinkedIssueTemplateValue(123)).toBe('123') + expect(formatLinkedIssueTemplateValue(1)).toBe('1') + }) + + it('renders anything that is not a positive integer as an empty string', () => { + // Why: `Fixes #-7` / `Fixes #1e+21` are worse output than `Fixes #`, so corrupt + // metadata degrades to the unlinked rendering instead of a nonsense reference. + for (const value of [ + 0, + -7, + 12.9, + 1e21, + null, + undefined, + Number.NaN, + Number.POSITIVE_INFINITY + ]) { + expect(formatLinkedIssueTemplateValue(value)).toBe('') + } + }) + + it('expands both brace forms and never leaves the token literal', () => { + const template = 'Fixes #{linkedIssue} / {{ linkedIssue }}' + expect( + renderSourceControlActionCommandTemplate(template, { + linkedIssue: formatLinkedIssueTemplateValue(42) + }) + ).toBe('Fixes #42 / 42') + expect( + renderSourceControlActionCommandTemplate(template, { + linkedIssue: formatLinkedIssueTemplateValue(null) + }) + ).toBe('Fixes # / ') + }) +}) + +describe('withLinkedIssueDraftContext', () => { + it('attaches only positive integers and leaves the context untouched otherwise', () => { + const context: CommitMessageDraftContext = { + branch: 'main', + stagedSummary: 'M a.ts', + stagedPatch: 'diff' + } + + expect(withLinkedIssueDraftContext(context, 42)).toEqual({ ...context, linkedIssue: 42 }) + for (const value of [null, undefined, Number.NaN, 0, -7, 12.9]) { + expect(withLinkedIssueDraftContext(context, value)).toBe(context) + } + }) +}) diff --git a/src/shared/source-control-ai-action-variables.ts b/src/shared/source-control-ai-action-variables.ts new file mode 100644 index 00000000000..0aab5cceb20 --- /dev/null +++ b/src/shared/source-control-ai-action-variables.ts @@ -0,0 +1,129 @@ +import type { SourceControlActionId } from './source-control-ai-actions' + +/** + * Registering a variable a hover card cannot describe is a compile error: the + * element type is keyed off `SOURCE_CONTROL_ACTION_VARIABLE_INFO`, so chips can + * never index a missing entry. + */ +export const SOURCE_CONTROL_ACTION_VARIABLES: Record< + SourceControlActionId, + SourceControlActionVariable[] +> = { + commitMessage: ['basePrompt', 'branch', 'stagedFiles', 'stagedPatch', 'linkedIssue'], + pullRequest: [ + 'basePrompt', + 'branch', + 'baseBranch', + 'currentTitle', + 'currentBody', + 'commitSummary', + 'changedFiles', + 'patch', + 'linkedIssue' + ], + branchName: ['basePrompt', 'firstPrompt', 'assistantMessage'], + fixCommitFailure: ['basePrompt'], + fixPushFailure: ['basePrompt'], + fixChecks: ['basePrompt'], + resolveConflicts: ['basePrompt'], + resolveComments: ['basePrompt'] +} + +export type SourceControlActionVariableInfo = { + description: string + example: string +} + +export const SOURCE_CONTROL_ACTION_VARIABLE_INFO = { + basePrompt: { + description: + 'Orca’s built-in prompt for this action, including the context Orca knows how to gather safely.', + example: + 'Commit messages include staged diff guidance; PR details include branch comparison guidance; fix actions include the failure summary.' + }, + branch: { + description: 'The current source-control branch name.', + example: 'feature/source-control-ai-recipes' + }, + stagedFiles: { + description: 'A newline-separated list of staged files for commit-message generation.', + example: 'M src/shared/source-control-ai.ts\nA src/shared/source-control-ai-actions.ts' + }, + stagedPatch: { + description: 'The staged git patch used for commit-message generation.', + example: 'diff --git a/src/app.ts b/src/app.ts\n+addActionRecipeDefaults()' + }, + baseBranch: { + description: 'The target branch selected in the Create PR composer.', + example: 'main' + }, + currentTitle: { + description: 'The PR title currently typed in the composer before generation starts.', + example: 'Improve Source Control AI customization' + }, + currentBody: { + description: 'The PR description currently typed in the composer before generation starts.', + example: 'Adds configurable agents and command templates for Source Control actions.' + }, + commitSummary: { + description: 'A newline-separated list of commits on the branch compared to the base.', + example: 'a1b2c3d Add action recipe defaults\nd4e5f6a Render command templates' + }, + changedFiles: { + description: 'A summary of files changed between the branch and the base branch.', + example: + 'src/shared/source-control-ai-actions.ts | 24 +++++\nsrc/main/text-generation.ts | 8 +-' + }, + patch: { + description: 'The branch diff against the base branch used for PR-details generation.', + example: 'diff --git a/src/app.ts b/src/app.ts\n+renderSourceControlActionCommandTemplate()' + }, + firstPrompt: { + description: 'The first user request that created the Orca workspace.', + example: 'Fix CI and commit the result' + }, + assistantMessage: { + description: 'The initial agent response, when Orca has one available.', + example: 'I will inspect the failing check, patch the issue, and run tests.' + }, + linkedIssue: { + description: + 'The GitHub issue number linked to this workspace. Empty when no GitHub issue is linked (including GitLab-linked workspaces). Prefer instructional templates: a bare "Fixes #{linkedIssue}" becomes "Fixes #" when unlinked.', + example: '123' + } +} satisfies Record + +export type SourceControlActionVariable = keyof typeof SOURCE_CONTROL_ACTION_VARIABLE_INFO + +/** + * Issue numbers are positive integers on every supported provider, so anything + * else (negative, zero, fractional, non-finite) is corrupt metadata rather than + * a renderable issue reference — `Fixes #-7` is worse output than `Fixes #`. + * The safe-integer bound also keeps the rendering in decimal notation: `String` + * switches to exponent form (`1e+21`) above it. + */ +export function isLinkedIssueNumber(linkedIssue: unknown): linkedIssue is number { + return typeof linkedIssue === 'number' && Number.isSafeInteger(linkedIssue) && linkedIssue > 0 +} + +/** + * Render the workspace-linked GitHub issue for template substitution. Anything + * that is not a positive integer becomes `''` so the token expands to nothing + * instead of leaking into the prompt. + */ +export function formatLinkedIssueTemplateValue(linkedIssue: number | null | undefined): string { + return isLinkedIssueNumber(linkedIssue) ? String(linkedIssue) : '' +} + +/** + * Attach a resolved issue number to a draft context. Returns the context untouched + * when nothing resolves, so unlinked workspaces keep their existing context shape. + * The `linkedIssue`-bearing constraint keeps the attach off contexts that do not + * declare the field (branch-name generation), where it would be silently unread. + */ +export function withLinkedIssueDraftContext( + context: T, + linkedIssue: number | null | undefined +): T { + return isLinkedIssueNumber(linkedIssue) ? { ...context, linkedIssue } : context +} diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts new file mode 100644 index 00000000000..e30e885ae94 --- /dev/null +++ b/src/shared/ssh-retained-payload-admission.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { getUtf8ByteLength } from './memory-safety/utf8-byte-limits' +import { + admitSshConnectionState, + admitSshDetectedPorts, + SSH_CONNECTION_ERROR_MAX_UTF8_BYTES, + SSH_DETECTED_PORTS_MAX_ENTRIES, + SSH_DETECTED_PORT_ADVERTISED_URL_MAX_UTF8_BYTES, + SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES, + SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES +} from './ssh-retained-payload-admission' + +describe('SSH retained payload admission', () => { + it('keeps ordinary connection state while stripping unknown payload fields', () => { + const admitted = admitSshConnectionState( + { + targetId: 'ssh-a', + status: 'connected', + error: null, + reconnectAttempt: 2, + connectionGeneration: 3, + supportsFolderDownload: true, + remotePlatform: 'linux', + unexpected: 'x'.repeat(1024) + }, + 'ssh-a' + ) + + expect(admitted).toEqual({ + targetId: 'ssh-a', + status: 'connected', + error: null, + reconnectAttempt: 2, + connectionGeneration: 3, + supportsFolderDownload: true, + remotePlatform: 'linux' + }) + }) + + it('caps connection errors without splitting a UTF-8 code point', () => { + const admitted = admitSshConnectionState( + { + targetId: 'ssh-a', + status: 'error', + error: `${'x'.repeat(SSH_CONNECTION_ERROR_MAX_UTF8_BYTES - 1)}🙂tail`, + reconnectAttempt: 0 + }, + 'ssh-a' + ) + + expect(admitted).not.toBeNull() + expect(getUtf8ByteLength(admitted?.error ?? '')).toBeLessThanOrEqual( + SSH_CONNECTION_ERROR_MAX_UTF8_BYTES + ) + expect(admitted?.error?.endsWith('\ud83d')).toBe(false) + }) + + it('rejects mismatched and oversized target identifiers', () => { + const state = { + targetId: 'ssh-a', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + + expect(admitSshConnectionState(state, 'ssh-b')).toBeNull() + expect( + admitSshConnectionState( + { ...state, targetId: 'x'.repeat(SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + 1) }, + 'x'.repeat(SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + 1) + ) + ).toBeNull() + }) + + it('caps port rows and their retained strings', () => { + const rows = Array.from({ length: SSH_DETECTED_PORTS_MAX_ENTRIES + 10 }, (_, index) => ({ + port: 1000 + index, + host: '127.0.0.1', + pid: index + 1, + processName: '🙂'.repeat(SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES), + advertisedUrl: `https://example.test/${'x'.repeat( + SSH_DETECTED_PORT_ADVERTISED_URL_MAX_UTF8_BYTES + )}`, + unexpected: 'retained only without admission' + })) + + const admitted = admitSshDetectedPorts(rows) + + expect(admitted).toHaveLength(SSH_DETECTED_PORTS_MAX_ENTRIES) + expect(getUtf8ByteLength(admitted[0].processName ?? '')).toBeLessThanOrEqual( + SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES + ) + expect(admitted[0].advertisedUrl).toBeUndefined() + expect(admitted[0]).not.toHaveProperty('unexpected') + }) + + it('drops malformed rows instead of retaining their payloads', () => { + expect( + admitSshDetectedPorts([ + { port: 0, host: '127.0.0.1' }, + { port: 3000, host: '' }, + { port: 3001, host: '127.0.0.1', processName: 'node' } + ]) + ).toEqual([{ port: 3001, host: '127.0.0.1', processName: 'node' }]) + }) +}) diff --git a/src/shared/ssh-retained-payload-admission.ts b/src/shared/ssh-retained-payload-admission.ts new file mode 100644 index 00000000000..1cd6afec88c --- /dev/null +++ b/src/shared/ssh-retained-payload-admission.ts @@ -0,0 +1,138 @@ +import type { EnrichedDetectedPort, SshConnectionState, SshConnectionStatus } from './ssh-types' +import { clampUtf8TextPrefix, measureUtf8ByteLength } from './memory-safety/utf8-byte-limits' + +export const SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES = 1024 +export const SSH_CONNECTION_ERROR_MAX_UTF8_BYTES = 16 * 1024 +export const SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES = 16 * 1024 +export const SSH_DETECTED_PORTS_MAX_ENTRIES = 50 +export const SSH_DETECTED_PORT_HOST_MAX_UTF8_BYTES = 1024 +export const SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES = 4 * 1024 +export const SSH_DETECTED_PORT_ADVERTISED_URL_MAX_UTF8_BYTES = 2048 + +const CONNECTION_STATUSES = new Set([ + 'disconnected', + 'connecting', + 'auth-failed', + 'deploying-relay', + 'connected', + 'reconnecting', + 'reconnection-failed', + 'error' +]) + +export function isSshRetainedIdentifier(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + !measureUtf8ByteLength(value, { + stopAfterBytes: SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + }).exceededLimit + ) +} + +export function admitSshConnectionState( + value: unknown, + expectedTargetId: string +): SshConnectionState | null { + if (!value || typeof value !== 'object' || !isSshRetainedIdentifier(expectedTargetId)) { + return null + } + const input = value as Record + if ( + (input.targetId !== undefined && + (!isSshRetainedIdentifier(input.targetId) || input.targetId !== expectedTargetId)) || + typeof input.status !== 'string' || + !CONNECTION_STATUSES.has(input.status as SshConnectionStatus) || + !isNonNegativeSafeInteger(input.reconnectAttempt) || + (input.error !== null && typeof input.error !== 'string') + ) { + return null + } + + const error = clampSshConnectionError(input.error) + return { + targetId: expectedTargetId, + status: input.status as SshConnectionStatus, + error, + reconnectAttempt: input.reconnectAttempt, + ...(isNonNegativeSafeInteger(input.connectionGeneration) + ? { connectionGeneration: input.connectionGeneration } + : {}), + ...(typeof input.supportsFolderDownload === 'boolean' + ? { supportsFolderDownload: input.supportsFolderDownload } + : {}), + ...(input.remotePlatform === 'linux' || + input.remotePlatform === 'darwin' || + input.remotePlatform === 'win32' + ? { remotePlatform: input.remotePlatform } + : {}) + } +} + +export function clampSshConnectionError(error: string | null): string | null { + return typeof error === 'string' + ? clampUtf8TextPrefix(error, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) + : null +} + +export function admitSshDetectedPorts(value: unknown): EnrichedDetectedPort[] { + if (!Array.isArray(value)) { + return [] + } + const retained: EnrichedDetectedPort[] = [] + const scanLimit = Math.min(value.length, SSH_DETECTED_PORTS_MAX_ENTRIES) + for (let index = 0; index < scanLimit; index += 1) { + const port = admitDetectedPort(value[index]) + if (port) { + retained.push(port) + } + } + return retained +} + +function admitDetectedPort(value: unknown): EnrichedDetectedPort | null { + if (!value || typeof value !== 'object') { + return null + } + const input = value as Record + if ( + !Number.isSafeInteger(input.port) || + (input.port as number) < 1 || + (input.port as number) > 65_535 || + !isStringWithinLimit(input.host, SSH_DETECTED_PORT_HOST_MAX_UTF8_BYTES) + ) { + return null + } + const processName = + typeof input.processName === 'string' + ? clampUtf8TextPrefix(input.processName, SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES) + : undefined + const advertisedUrl = isStringWithinLimit( + input.advertisedUrl, + SSH_DETECTED_PORT_ADVERTISED_URL_MAX_UTF8_BYTES + ) + ? input.advertisedUrl + : undefined + return { + port: input.port as number, + host: input.host, + ...(isNonNegativeSafeInteger(input.pid) && input.pid > 0 ? { pid: input.pid } : {}), + ...(processName ? { processName } : {}), + ...(advertisedUrl ? { advertisedUrl } : {}), + ...(input.advertisedProtocol === 'http' || input.advertisedProtocol === 'https' + ? { advertisedProtocol: input.advertisedProtocol } + : {}) + } +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0 +} + +function isStringWithinLimit(value: unknown, maxBytes: number): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + !measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit + ) +} diff --git a/src/shared/terminal-size-limits.ts b/src/shared/terminal-size-limits.ts new file mode 100644 index 00000000000..fca3d9c8754 --- /dev/null +++ b/src/shared/terminal-size-limits.ts @@ -0,0 +1,34 @@ +export const MAX_TERMINAL_COLS = 500 +export const MAX_TERMINAL_ROWS = 500 + +export function terminalSizeAdmissionError( + cols: unknown, + rows: unknown, + field: string, + options: { allowMissing?: boolean } = {} +): string | null { + for (const [name, value, max] of [ + ['cols', cols, MAX_TERMINAL_COLS], + ['rows', rows, MAX_TERMINAL_ROWS] + ] as const) { + if (options.allowMissing && value === undefined) { + continue + } + if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > max) { + return `${field}.${name} must be an integer from 1 through ${max}` + } + } + return null +} + +export function isValidTerminalSize(cols: unknown, rows: unknown): boolean { + return terminalSizeAdmissionError(cols, rows, 'terminal size') === null +} + +export function normalizeTerminalSize( + cols: unknown, + rows: unknown, + fallback: { cols: number; rows: number } = { cols: 80, rows: 24 } +): { cols: number; rows: number } { + return isValidTerminalSize(cols, rows) ? { cols: cols as number, rows: rows as number } : fallback +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 96612d90977..a76e2429904 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2727,8 +2727,10 @@ export type GlobalSettings = { terminalFocusFollowsMouse: boolean /** X11/gnome-terminal "copy on select": selecting text auto-copies to the clipboard; default off. */ terminalClipboardOnSelect: boolean - /** Enables OSC 52 clipboard writes for TUIs (SSH clipboard bridge); default off since OSC 52 is a clipboard-exfiltration vector. */ + /** Enables OSC 52 clipboard writes for TUIs (tmux/Zellij/nvim, incl. over SSH); default on. Clipboard *queries* stay blocked and payload size is capped, so this is write-only exposure. */ terminalAllowOsc52Clipboard: boolean + /** One-shot stamp: profiles saved under the old off default get flipped on once, after which an explicit opt-out sticks. */ + terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean /** Experimental Claude Agent Teams; native panes use a tmux-compatible shim so teammate output stays on the normal PTY path. */ claudeAgentTeamsMode?: ClaudeAgentTeamsMode /** Where the repo setup script runs on workspace create; defaults to a background "Setup" tab to keep the main terminal usable. */ @@ -3301,6 +3303,8 @@ export type PersistedUIState = { browserImportHintHidden?: boolean /** Why: Windows-only. Set once on first hide to tray so the "Orca is still running" notice shows only once. */ trayMinimizeNoticeShown?: boolean + /** Set by the OSC 52 default-on migration when it overrode a persisted `false`; the renderer shows one notice and clears it. */ + osc52ClipboardDefaultOnNoticePending?: boolean /** User dismissed the first-run Mobile Emulator intro; reversible only by re-enabling the feature in Settings. */ mobileEmulatorTabIntroDismissed?: boolean /** User deferred the in-pane Mobile Emulator CLI + skill setup guide. */ diff --git a/tests/e2e/codex-composer-echo-latency-probe.ts b/tests/e2e/codex-composer-echo-latency-probe.ts new file mode 100644 index 00000000000..9fc9cabf2c7 --- /dev/null +++ b/tests/e2e/codex-composer-echo-latency-probe.ts @@ -0,0 +1,203 @@ +import type { Page } from '@stablyai/playwright-test' + +export type CodexEchoLatencySample = { + index: number + char: string + /** keydown -> xterm finished parsing the echoed glyph (real echo latency). */ + keyToParseMs: number + /** keydown -> xterm renderer painted the row carrying that glyph. */ + keyToRenderMs: number | null +} + +export type CodexEchoProbeReport = { + samples: CodexEchoLatencySample[] + keysObserved: number + parseEvents: number + renderEvents: number + cols: number + rows: number +} + +declare global { + // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface + interface Window { + __codexEchoProbe?: { + report(): CodexEchoProbeReport + dispose(): void + } + } +} + +/** + * Installs an in-renderer echo-latency recorder on the active terminal pane. + * + * Why in-page: polling a serialized buffer over CDP adds serialize + IPC + + * poll-granularity cost to every sample, which swamped the signal it measured. + * Timestamps here are taken inside the renderer with performance.now(), so the + * measured window contains no cross-process work at all. + */ +export async function installCodexEchoLatencyProbe(page: Page, target: string): Promise { + await page.evaluate((target) => { + type PendingSample = { + index: number + char: string + expected: string + startedAt: number + parsedAt: number | null + } + + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('Codex echo probe: no active terminal pane') + } + const terminal = pane.terminal + if (typeof terminal.onWriteParsed !== 'function') { + throw new Error('Codex echo probe: xterm build has no onWriteParsed') + } + + const samples: CodexEchoLatencySample[] = [] + const awaitingRender: { sample: CodexEchoLatencySample; startedAt: number }[] = [] + // Why a queue, not one slot: a slow echo can still be outstanding when the + // next key is pressed, and a single slot silently discards that sample. + const pending: PendingSample[] = [] + let keysObserved = 0 + let parseEvents = 0 + let renderEvents = 0 + + // Why concatenated without a separator: a composer line that wraps splits the + // token across rows, and trailing-trimmed rows rejoin exactly at the break. + const viewportText = (): string => { + const buffer = terminal.buffer.active + let text = '' + for (let row = 0; row < terminal.rows; row += 1) { + text += buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + } + return text + } + + const observeParse = (): void => { + parseEvents += 1 + if (pending.length === 0) { + return + } + const text = viewportText() + // Why drain in order: one parse can land several queued keystrokes at + // once, and each still gets credited against its own keydown timestamp. + while (pending.length > 0 && text.includes(pending[0].expected)) { + const entry = pending.shift() + if (!entry) { + break + } + entry.parsedAt = performance.now() + const sample: CodexEchoLatencySample = { + index: entry.index, + char: entry.char, + keyToParseMs: entry.parsedAt - entry.startedAt, + keyToRenderMs: null + } + samples.push(sample) + awaitingRender.push({ sample, startedAt: entry.startedAt }) + } + } + + const observeRender = (): void => { + renderEvents += 1 + const paintedAt = performance.now() + for (const entry of awaitingRender.splice(0, awaitingRender.length)) { + entry.sample.keyToRenderMs = paintedAt - entry.startedAt + } + } + + // Why window capture: a listener on an ancestor in the capture phase is + // guaranteed to run before xterm's own keydown handler forwards to the PTY, + // so t0 is stamped before any of the work being measured starts. + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key.length !== 1 || keysObserved >= target.length) { + return + } + const index = keysObserved + keysObserved += 1 + pending.push({ + index, + char: target[index], + expected: target.slice(0, index + 1), + startedAt: performance.now(), + parsedAt: null + }) + } + + window.addEventListener('keydown', onKeyDown, { capture: true }) + const parsedDisposable = terminal.onWriteParsed(observeParse) + const renderDisposable = terminal.onRender(observeRender) + + window.__codexEchoProbe = { + report: () => ({ + samples: [...samples], + keysObserved, + parseEvents, + renderEvents, + cols: terminal.cols, + rows: terminal.rows + }), + dispose: () => { + window.removeEventListener('keydown', onKeyDown, { capture: true }) + parsedDisposable.dispose() + renderDisposable.dispose() + } + } + }, target) +} + +/** Drains every recorded sample in a single round-trip once typing has finished. */ +export async function collectCodexEchoLatencyReport(page: Page): Promise { + return page.evaluate(() => { + const probe = window.__codexEchoProbe + if (!probe) { + throw new Error('Codex echo probe was never installed') + } + const report = probe.report() + probe.dispose() + return report + }) +} + +export type LatencyDistribution = { + count: number + p50: number + p95: number + max: number +} + +function percentile(sorted: number[], quantile: number): number { + if (sorted.length === 0) { + return 0 + } + const rank = Math.min(sorted.length - 1, Math.ceil(quantile * sorted.length) - 1) + return sorted[Math.max(0, rank)] +} + +export function summarizeLatencies(values: number[]): LatencyDistribution { + const sorted = [...values].sort((a, b) => a - b) + return { + count: sorted.length, + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95), + max: sorted.at(-1) ?? 0 + } +} + +export function formatDistribution(label: string, distribution: LatencyDistribution): string { + return ( + `${label} n=${distribution.count} p50=${distribution.p50.toFixed(1)}ms ` + + `p95=${distribution.p95.toFixed(1)}ms max=${distribution.max.toFixed(1)}ms` + ) +} diff --git a/tests/e2e/helpers/alt-screen-frame.ts b/tests/e2e/helpers/alt-screen-frame.ts new file mode 100644 index 00000000000..6292b0c84e1 --- /dev/null +++ b/tests/e2e/helpers/alt-screen-frame.ts @@ -0,0 +1,76 @@ +import type { Page } from '@stablyai/playwright-test' + +// Boxed alt-screen TUI frame: enters the alternate buffer, clears it, and paints +// a marker line carrying a zero-padded frame number. +export function buildAltScreenFrame(marker: string, frame: number): string { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + `╭────────────────────────────────────────────────────────────────────╮`, + `│ ${marker} frame ${String(frame).padStart(3, '0')} ${progress} │`, + `│ Dimension │ Rating │`, + `╰────────────────────────────────────────────────────────────────────╯`, + '\x1b[?2026l' + ].join('\r\n') +} + +// Why: the live-write and the reveal restore paint the same layout, so the frame +// number is the only thing on screen that says which of the two landed last. +export async function readRenderedAltScreenFrame( + page: Page, + tabId: string, + marker: string +): Promise { + return page.evaluate( + ({ tabId, marker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + // marker is a literal, so escape it rather than letting `[`/`.`/`+` act as regex syntax. + const pattern = new RegExp(`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} frame (\\d{3})`) + const buffer = pane.terminal.buffer.active + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + const match = pattern.exec(line) + if (match) { + return Number(match[1]) + } + } + return null + }, + { tabId, marker } + ) +} + +export function describeAltScreenRenderPath( + renderedFrame: number | null, + liveFrame: number, + restoreFrame: number +): string { + if (renderedFrame === restoreFrame) { + return 'reveal restore' + } + if (renderedFrame === liveFrame) { + return 'live write (no restore)' + } + return renderedFrame === null ? 'no marker' : `unexpected frame ${renderedFrame}` +} + +export async function writeToPaneTerminal(page: Page, tabId: string, data: string): Promise { + await page.evaluate( + ({ tabId, data }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + return new Promise((resolve) => pane.terminal.write(data, resolve)) + }, + { tabId, data } + ) +} diff --git a/tests/e2e/source-control-pr-linked-issue-ai.spec.ts b/tests/e2e/source-control-pr-linked-issue-ai.spec.ts new file mode 100644 index 00000000000..625d19d58b8 --- /dev/null +++ b/tests/e2e/source-control-pr-linked-issue-ai.spec.ts @@ -0,0 +1,96 @@ +import { rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + createBranchCommit, + openSourceControl, + seedCreatePrComposer +} from './helpers/source-control-ai-generation' +import { writeLinkedIssueEchoGenerator } from './helpers/source-control-ai-generators' +import { waitForSessionReady } from './helpers/store' + +// Why: the PR path reads the echoed issue from the generated title. +function writeLinkedIssuePrEchoGenerator(scriptPath: string, base: string): void { + writeLinkedIssueEchoGenerator(scriptPath, [ + ' process.stdout.write(JSON.stringify({', + ` base: ${JSON.stringify(base)},`, + ' title: `saw-issue:${issue}`,', + " body: 'linked-issue e2e body',", + ' draft: false', + ' }))' + ]) +} + +test.describe('Source Control AI pull request linkedIssue', () => { + // Why: the unlinked case separates a real resolver from one that always returns a number, + // and — because the generator echoes the whole line into the title — a literal + // `{linkedIssue}` reaches the assertion as `saw-issue:{linkedIssue}` instead of + // masquerading as the empty expansion. + for (const { label, linkedIssue, expected } of [ + { label: 'substitutes the workspace-linked issue into', linkedIssue: 4242, expected: '4242' }, + { + label: 'expands the issue token to nothing for an unlinked workspace in', + linkedIssue: null, + expected: 'empty' + } + ]) { + test(`${label} the pull-request recipe`, async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const { prWorktreeId, prWorktreePath, primaryBranch } = await seedCreatePrComposer(orcaPage) + createBranchCommit(prWorktreePath) + + const generatorPath = path.join( + os.tmpdir(), + `e2e-pr-linked-issue-${Date.now()}-${Math.random().toString(16).slice(2)}.cjs` + ) + writeLinkedIssuePrEchoGenerator(generatorPath, primaryBranch) + + try { + await orcaPage.evaluate( + async ({ generatorPath, linkedIssue, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + await window.api.worktrees.updateMeta({ worktreeId, updates: { linkedIssue } }) + const customAgentCommand = `node ${JSON.stringify(generatorPath)}` + await store.getState().updateSettings({ + activeRuntimeEnvironmentId: null, + sourceControlAi: { + enabled: true, + agentId: 'custom' as const, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customAgentCommand, + instructionsByOperation: {}, + actions: { + pullRequest: { + agentId: 'custom' as const, + commandInputTemplate: 'ORCA_E2E_ISSUE={linkedIssue}\n\n{basePrompt}' + } + } + } + }) + }, + { generatorPath, linkedIssue, worktreeId: prWorktreeId } + ) + + await openSourceControl(orcaPage, prWorktreeId) + + const title = orcaPage.getByRole('textbox', { name: 'Pull request title' }) + await expect(title).toBeVisible({ timeout: 10_000 }) + + const generate = orcaPage.getByRole('button', { + name: 'Generate pull request details with AI' + }) + await expect(generate).toBeEnabled() + await generate.click() + + await expect(title).toHaveValue(`saw-issue:${expected}`, { timeout: 15_000 }) + } finally { + rmSync(generatorPath, { force: true }) + } + }) + } +}) diff --git a/tests/e2e/ssh-external-image-preview.spec.ts b/tests/e2e/ssh-external-image-preview.spec.ts deleted file mode 100644 index 6605403dd70..00000000000 --- a/tests/e2e/ssh-external-image-preview.spec.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { createHash } from 'node:crypto' -import type { Page } from '@stablyai/playwright-test' -import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' -import { - cleanupDockerSshRelayTarget, - DOCKER_SSH_RELAY_REMOTE_REPO_PATH, - execDockerSshRelayTargetCommand, - shellQuote, - startDockerSshRelayTarget, - type DockerSshRelayTarget -} from './helpers/docker-ssh-relay-target' -import { test, expect } from './helpers/orca-app' -import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { - getTerminalContent, - sendToTerminal, - waitForActivePanePtyId, - waitForActiveTerminalManager -} from './helpers/terminal' - -const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' -const REMOTE_IMAGE_PATH = '/tmp/orca-ssh-external-preview.png' -const IMAGE_BASE64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVR4AWN8z8DwnwEJMDGgAcICAO2mBAXmO4drAAAAAElFTkSuQmCC' - -type LinkProbe = { col: number; row: number; tabId: string } - -async function findTerminalLink(page: Page, text: string): Promise { - return page.evaluate((text) => { - const state = window.__store?.getState() - const tabId = state?.activeTabId ?? null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!tabId || !pane) { - throw new Error('Active terminal pane is unavailable') - } - const terminal = pane.terminal - for (let row = 0; row < terminal.rows; row += 1) { - const line = terminal.buffer.active.getLine(terminal.buffer.active.viewportY + row) - const col = line?.translateToString(true).indexOf(text) ?? -1 - if (col >= 0) { - return { col: col + Math.floor(text.length / 2), row, tabId } - } - } - throw new Error('External image path is not visible in the terminal') - }, text) -} - -async function activateTerminalLink(page: Page, probe: LinkProbe, text: string): Promise { - await expect - .poll( - async () => { - await page.evaluate(({ col, row, tabId }) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const screen = pane?.terminal.element?.querySelector('.xterm-screen') - if (!pane || !screen) { - throw new Error('Active terminal screen is unavailable') - } - const rect = screen.getBoundingClientRect() - screen.dispatchEvent( - new MouseEvent('mousemove', { - bubbles: true, - cancelable: true, - clientX: rect.left + (col + 0.5) * (rect.width / pane.terminal.cols), - clientY: rect.top + (row + 0.5) * (rect.height / pane.terminal.rows) - }) - ) - }, probe) - return page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const core = pane?.terminal as unknown as - | { _core?: { linkifier?: { currentLink?: { link?: { text?: string } } } } } - | undefined - return core?._core?.linkifier?.currentLink?.link?.text ?? null - }, probe.tabId) - }, - { timeout: 10_000, message: 'External SSH image path did not become clickable' } - ) - .toContain(text) - - await page.evaluate(({ col, row, tabId }) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const screen = pane?.terminal.element?.querySelector('.xterm-screen') - if (!pane || !screen) { - throw new Error('Active terminal screen is unavailable') - } - const rect = screen.getBoundingClientRect() - const mouse = { - bubbles: true, - cancelable: true, - button: 0, - clientX: rect.left + (col + 0.5) * (rect.width / pane.terminal.cols), - clientY: rect.top + (row + 0.5) * (rect.height / pane.terminal.rows), - metaKey: navigator.userAgent.includes('Mac'), - ctrlKey: !navigator.userAgent.includes('Mac') - } - screen.dispatchEvent(new MouseEvent('mousedown', { ...mouse, buttons: 1 })) - screen.dispatchEvent(new MouseEvent('mouseup', mouse)) - }, probe) -} - -test.describe('SSH external image preview', () => { - test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') - test.skip(process.platform === 'win32', 'The disposable SSH host uses POSIX tooling.') - - test('opens an image outside the worktree from a terminal link', async ({ - orcaPage, - registerPostElectronShutdownCleanup - }, testInfo) => { - test.slow() - let target: DockerSshRelayTarget | null = null - let cleanupDeferred = false - try { - target = startDockerSshRelayTarget(testInfo) - registerPostElectronShutdownCleanup(async () => cleanupDockerSshRelayTarget(target)) - cleanupDeferred = true - execDockerSshRelayTargetCommand( - target, - `printf '%s' ${shellQuote(IMAGE_BASE64)} | base64 -d > ${shellQuote(REMOTE_IMAGE_PATH)}` - ) - - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - const remote = await connectDockerSshRelayTarget(orcaPage, target, { - remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH - }) - await ensureTerminalVisible(orcaPage, 45_000) - await waitForActiveTerminalManager(orcaPage, 60_000) - const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) - const readyMarker = `SSH_PREVIEW_READY_${Date.now()}` - const encodedReadyMarker = Buffer.from(readyMarker).toString('base64') - await sendToTerminal( - orcaPage, - ptyId, - `printf '%s' ${shellQuote(encodedReadyMarker)} | base64 -d; printf '\\n'\r` - ) - await expect - .poll(() => getTerminalContent(orcaPage, 30_000), { - timeout: 15_000, - message: 'SSH terminal did not execute the readiness marker' - }) - .toContain(readyMarker) - - await sendToTerminal(orcaPage, ptyId, `printf '%s\\n' ${shellQuote(REMOTE_IMAGE_PATH)}\r`) - await expect - .poll(() => getTerminalContent(orcaPage, 30_000), { - timeout: 15_000, - message: 'External image path did not reach the SSH terminal' - }) - .toContain(REMOTE_IMAGE_PATH) - - const probe = await findTerminalLink(orcaPage, REMOTE_IMAGE_PATH) - await activateTerminalLink(orcaPage, probe, REMOTE_IMAGE_PATH) - - const preview = orcaPage.locator(`img[alt="${REMOTE_IMAGE_PATH.split('/').at(-1)}"]`) - await expect(preview).toBeVisible({ timeout: 30_000 }) - expect(await preview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe( - 2 - ) - expect(await preview.getAttribute('src')).toBe(`data:image/png;base64,${IMAGE_BASE64}`) - await expect(orcaPage.getByText('Unable to load file', { exact: true })).toHaveCount(0) - - const state = await orcaPage.evaluate((filePath) => { - const file = window.__store?.getState().openFiles.find((item) => item.filePath === filePath) - return file - ? { - externalSshTargetId: file.externalSshTargetId, - relativePath: file.relativePath - } - : null - }, REMOTE_IMAGE_PATH) - expect(state).toEqual({ - externalSshTargetId: remote.targetId, - relativePath: REMOTE_IMAGE_PATH - }) - - const remoteHash = execDockerSshRelayTargetCommand( - target, - `sha256sum ${shellQuote(REMOTE_IMAGE_PATH)} | cut -d' ' -f1` - ) - expect(remoteHash).toBe( - createHash('sha256').update(Buffer.from(IMAGE_BASE64, 'base64')).digest('hex') - ) - await testInfo.attach('ssh-external-image-preview', { - body: await orcaPage.screenshot(), - contentType: 'image/png' - }) - } finally { - if (!cleanupDeferred) { - cleanupDockerSshRelayTarget(target) - } - } - }) -})