diff --git a/config/bounded-ingress-baseline.json b/config/bounded-ingress-baseline.json new file mode 100644 index 00000000000..856f511a96a --- /dev/null +++ b/config/bounded-ingress-baseline.json @@ -0,0 +1,728 @@ +{ + "allowed": [ + "mobile/src/cache/home-snapshot-cache.ts:json-parse", + "mobile/src/cache/home-snapshot-cache.ts:json-stringify", + "mobile/src/components/CustomKeyModal.tsx:json-parse", + "mobile/src/components/CustomKeyModal.tsx:json-stringify", + "mobile/src/components/MobileRichMarkdownEditor.tsx:json-parse", + "mobile/src/components/mobile-rich-markdown-editor-html.ts:json-stringify", + "mobile/src/components/pr-sidebar/MermaidDiagram.tsx:json-stringify", + "mobile/src/files/mobile-file-preview-source.ts:json-stringify", + "mobile/src/session/MobileNativeChatView.tsx:json-stringify", + "mobile/src/session/mobile-image-source-picker.ts:response-text", + "mobile/src/session/mobile-native-chat-permission.ts:json-parse", + "mobile/src/session/mobile-native-chat-question.ts:json-stringify", + "mobile/src/session/mobile-terminal-records.ts:json-stringify", + "mobile/src/session/use-mobile-native-chat-ask-dismiss.ts:json-stringify", + "mobile/src/storage/codex-reset-attempt-journal.ts:json-parse", + "mobile/src/storage/codex-reset-attempt-journal.ts:json-stringify", + "mobile/src/storage/preferences.ts:json-parse", + "mobile/src/storage/preferences.ts:json-stringify", + "mobile/src/storage/session-view-preferences.ts:json-parse", + "mobile/src/storage/session-view-preferences.ts:json-stringify", + "mobile/src/terminal/TerminalWebView.tsx:json-parse", + "mobile/src/terminal/TerminalWebView.tsx:json-stringify", + "mobile/src/terminal/terminal-accessory-layout.ts:json-parse", + "mobile/src/terminal/terminal-accessory-layout.ts:json-stringify", + "mobile/src/terminal/terminal-webview-engine.generated.ts:json-parse", + "mobile/src/terminal/terminal-webview-engine.generated.ts:json-stringify", + "mobile/src/terminal/terminal-webview-html.ts:json-parse", + "mobile/src/terminal/terminal-webview-html.ts:json-stringify", + "mobile/src/terminal/terminal-webview-url-tap.ts:json-stringify", + "mobile/src/transport/browser-screencast-protocol.ts:json-parse", + "mobile/src/transport/host-credential-cleanup.ts:json-parse", + "mobile/src/transport/host-credential-cleanup.ts:json-stringify", + "mobile/src/transport/host-store.ts:json-parse", + "mobile/src/transport/host-store.ts:json-stringify", + "mobile/src/transport/mobile-e2ee-v2-physical-channel.ts:json-parse", + "mobile/src/transport/mobile-e2ee-v2-physical-channel.ts:json-stringify", + "mobile/src/transport/mobile-relay-credential-bundle.ts:json-parse", + "mobile/src/transport/mobile-relay-credential-bundle.ts:json-stringify", + "mobile/src/transport/mobile-relay-credential-rotation.ts:json-stringify", + "mobile/src/transport/mobile-relay-direct-upgrade-journal.ts:json-parse", + "mobile/src/transport/mobile-relay-direct-upgrade-journal.ts:json-stringify", + "mobile/src/transport/mobile-relay-direct-upgrade.ts:json-stringify", + "mobile/src/transport/mobile-relay-e2ee-link.ts:json-parse", + "mobile/src/transport/mobile-relay-e2ee-link.ts:json-stringify", + "mobile/src/transport/mobile-relay-host-overlay-store.ts:json-parse", + "mobile/src/transport/mobile-relay-host-overlay-store.ts:json-stringify", + "mobile/src/transport/mobile-relay-invite-director.ts:json-parse", + "mobile/src/transport/mobile-relay-invite-director.ts:json-stringify", + "mobile/src/transport/mobile-relay-pairing-journal-store.ts:json-parse", + "mobile/src/transport/mobile-relay-pairing-journal-store.ts:json-stringify", + "mobile/src/transport/mobile-relay-pairing-journal.ts:json-stringify", + "mobile/src/transport/mobile-relay-pairing-recovery.ts:json-stringify", + "mobile/src/transport/mobile-relay-physical-client.ts:json-parse", + "mobile/src/transport/mobile-relay-physical-client.ts:json-stringify", + "mobile/src/transport/mobile-relay-resume-director.ts:json-parse", + "mobile/src/transport/mobile-relay-resume-director.ts:json-stringify", + "mobile/src/transport/mobile-relay-resume-director.ts:response-text", + "mobile/src/transport/mobile-relay-rpc-session.ts:json-parse", + "mobile/src/transport/mobile-relay-rpc-session.ts:json-stringify", + "mobile/src/transport/pairing.ts:json-parse", + "mobile/src/transport/pre-profile-pairing-coordinator.ts:json-stringify", + "mobile/src/transport/rpc-client.ts:json-parse", + "mobile/src/transport/rpc-client.ts:json-stringify", + "mobile/src/transport/socket-event-debug.ts:json-stringify", + "mobile/src/transport/terminal-stream-protocol.ts:json-parse", + "mobile/src/transport/websocket-payload-bytes.ts:response-text", + "mobile/src/worktree/last-visited-worktree-repo.ts:json-parse", + "src/cli/bundled-skill-guides.ts:json-parse", + "src/cli/bundled-skill-guides.ts:json-stringify", + "src/cli/bundled-skill-guides.ts:read-file", + "src/cli/format.ts:json-stringify", + "src/cli/handlers/agent-hooks.ts:json-parse", + "src/cli/handlers/agent-hooks.ts:json-stringify", + "src/cli/handlers/agent-hooks.ts:read-file", + "src/cli/handlers/automations.ts:json-parse", + "src/cli/handlers/browser-env.ts:json-stringify", + "src/cli/handlers/browser-interact.ts:json-stringify", + "src/cli/handlers/browser-nav.ts:json-stringify", + "src/cli/handlers/browser-storage.ts:json-stringify", + "src/cli/handlers/browser-tab.ts:json-stringify", + "src/cli/handlers/emulator.ts:json-parse", + "src/cli/handlers/emulator.ts:json-stringify", + "src/cli/handlers/introspection.ts:json-stringify", + "src/cli/handlers/orchestration.ts:json-stringify", + "src/cli/handlers/skills.ts:json-stringify", + "src/cli/handlers/vm.ts:json-stringify", + "src/cli/handlers/vm.ts:read-file", + "src/cli/linear-request-builders.ts:read-file", + "src/cli/runtime/mac-app-update-bundle.ts:read-file", + "src/cli/runtime/metadata.ts:json-parse", + "src/cli/runtime/metadata.ts:read-file", + "src/cli/runtime/serve-update-supervisor.ts:json-parse", + "src/cli/runtime/serve-update-supervisor.ts:json-stringify", + "src/cli/runtime/serve-update-supervisor.ts:read-file", + "src/cli/runtime/transport.ts:json-parse", + "src/cli/runtime/transport.ts:json-stringify", + "src/cli/workspace-format.ts:json-stringify", + "src/main/active-view-preference.ts:json-parse", + "src/main/active-view-preference.ts:json-stringify", + "src/main/active-view-preference.ts:read-file", + "src/main/agent-hooks/hooks-json-read.ts:json-parse", + "src/main/agent-hooks/hooks-json-read.ts:read-file", + "src/main/agent-hooks/install-telemetry.ts:json-stringify", + "src/main/agent-hooks/installer-utils-remote.ts:json-parse", + "src/main/agent-hooks/installer-utils-remote.ts:json-stringify", + "src/main/agent-hooks/installer-utils-remote.ts:read-file", + "src/main/agent-hooks/installer-utils.ts:json-stringify", + "src/main/agent-hooks/installer-utils.ts:read-file", + "src/main/agent-hooks/managed-hook-local-filesystem.ts:read-file", + "src/main/agent-hooks/managed-hook-lock-claims.ts:json-stringify", + "src/main/agent-hooks/managed-hook-lock-claims.ts:read-file", + "src/main/agent-hooks/managed-hook-lock-records.ts:json-parse", + "src/main/agent-hooks/managed-hook-lock-records.ts:json-stringify", + "src/main/agent-hooks/managed-hook-lock-records.ts:read-file", + "src/main/agent-hooks/managed-hook-owner-identity.ts:read-file", + "src/main/agent-hooks/server.ts:json-parse", + "src/main/agent-hooks/server.ts:json-stringify", + "src/main/agent-hooks/server.ts:read-file", + "src/main/agent-hooks/wsl-hook-fs-adapter.ts:read-file", + "src/main/agent-hooks/wsl-hook-relay-deps.ts:read-file", + "src/main/agent-hooks/wsl-hook-relay-launch.ts:read-file", + "src/main/agent-state-file-reader.ts:json-parse", + "src/main/agent-trust-presets.ts:json-parse", + "src/main/agent-trust-presets.ts:json-stringify", + "src/main/agent-trust-presets.ts:read-file", + "src/main/ai-vault/cached-session-list.ts:json-stringify", + "src/main/ai-vault/remote-session-scanner-codex-index.ts:read-file", + "src/main/ai-vault/remote-session-scanner-discovery.ts:unbounded-fanout", + "src/main/ai-vault/remote-session-scanner.ts:read-file", + "src/main/ai-vault/remote-session-scanner.ts:unbounded-fanout", + "src/main/ai-vault/session-parse-cache-persistence.ts:json-parse", + "src/main/ai-vault/session-parse-cache-persistence.ts:json-stringify", + "src/main/ai-vault/session-parse-cache-persistence.ts:read-file", + "src/main/ai-vault/session-scanner-antigravity-fixtures.ts:json-stringify", + "src/main/ai-vault/session-scanner-claude-subagents.ts:json-parse", + "src/main/ai-vault/session-scanner-claude-subagents.ts:read-file", + "src/main/ai-vault/session-scanner-codex-fixtures.ts:json-stringify", + "src/main/ai-vault/session-scanner-devin-parser.ts:json-parse", + "src/main/ai-vault/session-scanner-devin-parser.ts:read-file", + "src/main/ai-vault/session-scanner-gemini-parsers.ts:json-parse", + "src/main/ai-vault/session-scanner-gemini-parsers.ts:read-file", + "src/main/ai-vault/session-scanner-graph-parsers.ts:json-parse", + "src/main/ai-vault/session-scanner-graph-parsers.ts:read-file", + "src/main/ai-vault/session-scanner-grok-parser.ts:json-parse", + "src/main/ai-vault/session-scanner-grok-parser.ts:read-file", + "src/main/ai-vault/session-scanner-incremental-fixtures.ts:json-stringify", + "src/main/ai-vault/session-scanner-kimi-parser.ts:json-parse", + "src/main/ai-vault/session-scanner-kimi-parser.ts:read-file", + "src/main/ai-vault/session-scanner-kimi-paths.ts:json-parse", + "src/main/ai-vault/session-scanner-opencode-sqlite.ts:json-parse", + "src/main/ai-vault/session-scanner-secondary-parsers.ts:json-parse", + "src/main/ai-vault/session-scanner-secondary-parsers.ts:read-file", + "src/main/ai-vault/session-scanner-test-fixtures.ts:json-stringify", + "src/main/ai-vault/session-scanner-values.ts:json-parse", + "src/main/ai-vault/session-scanner-values.ts:read-file", + "src/main/ai-vault/session-scanner.ts:read-file", + "src/main/amp/hook-service.ts:json-stringify", + "src/main/amp/hook-service.ts:read-file", + "src/main/attribution/terminal-attribution.ts:read-file", + "src/main/automations/external-manager.ts:json-parse", + "src/main/automations/external-manager.ts:read-file", + "src/main/automations/hermes-cron-output.ts:read-file", + "src/main/automations/hermes-cron-output.ts:unbounded-fanout", + "src/main/azure-devops/azure-devops-api-request.ts:response-text", + "src/main/azure-devops/pull-request-creation.ts:json-stringify", + "src/main/bitbucket/client.ts:response-text", + "src/main/browser/agent-browser-bridge.ts:json-parse", + "src/main/browser/agent-browser-bridge.ts:json-stringify", + "src/main/browser/agent-browser-bridge.ts:read-file", + "src/main/browser/browser-clicked-link-routing.ts:json-stringify", + "src/main/browser/browser-cookie-import.ts:json-parse", + "src/main/browser/browser-cookie-import.ts:read-file", + "src/main/browser/browser-manager.ts:json-stringify", + "src/main/browser/browser-session-registry.ts:json-parse", + "src/main/browser/browser-session-registry.ts:json-stringify", + "src/main/browser/browser-session-registry.ts:read-file", + "src/main/browser/cdp-bridge.ts:json-parse", + "src/main/browser/cdp-bridge.ts:json-stringify", + "src/main/browser/cdp-ws-proxy-test-harness.ts:json-parse", + "src/main/browser/cdp-ws-proxy-test-harness.ts:json-stringify", + "src/main/browser/cdp-ws-proxy.ts:json-parse", + "src/main/browser/cdp-ws-proxy.ts:json-stringify", + "src/main/browser/popup-origin-bar-window.ts:json-stringify", + "src/main/browser/snapshot-engine.ts:json-parse", + "src/main/browser/snapshot-engine.ts:json-stringify", + "src/main/claude-accounts/managed-auth-path.ts:read-file", + "src/main/claude-accounts/oauth-refresh.ts:json-parse", + "src/main/claude-accounts/oauth-refresh.ts:json-stringify", + "src/main/claude-accounts/oauth-refresh.ts:response-text", + "src/main/claude-accounts/runtime-auth-service.ts:json-parse", + "src/main/claude-accounts/runtime-auth-service.ts:json-stringify", + "src/main/claude-accounts/runtime-auth-service.ts:read-file", + "src/main/claude-accounts/service.ts:json-parse", + "src/main/claude-accounts/service.ts:json-stringify", + "src/main/claude-accounts/service.ts:read-file", + "src/main/claude-usage/scanner.ts:json-parse", + "src/main/claude-usage/scanner.ts:unbounded-fanout", + "src/main/claude-usage/store.ts:json-parse", + "src/main/claude-usage/store.ts:json-stringify", + "src/main/claude-usage/store.ts:read-file", + "src/main/claude/hook-settings.ts:json-stringify", + "src/main/cli/cli-installer.ts:read-file", + "src/main/cli/linux-bare-orca-dispatcher.ts:read-file", + "src/main/cli/linux-terminal-orca-cli-shim.ts:read-file", + "src/main/cli/wsl-cli-registration-registry.ts:json-parse", + "src/main/cli/wsl-cli-registration-registry.ts:json-stringify", + "src/main/cli/wsl-cli-registration-registry.ts:read-file", + "src/main/codex-accounts/codex-auth-identity.ts:json-parse", + "src/main/codex-accounts/host-codex-managed-home-ownership.ts:read-file", + "src/main/codex-accounts/legacy-shared-auth-migration.ts:json-stringify", + "src/main/codex-accounts/legacy-shared-auth-migration.ts:read-file", + "src/main/codex-accounts/runtime-home-service.ts:json-parse", + "src/main/codex-accounts/runtime-home-service.ts:json-stringify", + "src/main/codex-accounts/runtime-home-service.ts:read-file", + "src/main/codex-accounts/service.ts:json-parse", + "src/main/codex-accounts/service.ts:json-stringify", + "src/main/codex-accounts/service.ts:read-file", + "src/main/codex-usage/scanner.ts:json-parse", + "src/main/codex-usage/store.ts:json-parse", + "src/main/codex-usage/store.ts:json-stringify", + "src/main/codex-usage/store.ts:read-file", + "src/main/codex/codex-app-server-grant-bridge.ts:json-parse", + "src/main/codex/codex-app-server-grant-bridge.ts:json-stringify", + "src/main/codex/codex-app-server-grant-entry.ts:json-parse", + "src/main/codex/codex-app-server-grant-entry.ts:json-stringify", + "src/main/codex/codex-app-server-session.ts:json-parse", + "src/main/codex/codex-app-server-session.ts:json-stringify", + "src/main/codex/codex-home-paths.ts:json-parse", + "src/main/codex/codex-home-paths.ts:json-stringify", + "src/main/codex/codex-home-paths.ts:read-file", + "src/main/codex/codex-hook-identity.ts:json-stringify", + "src/main/codex/codex-real-home-hook-install.ts:read-file", + "src/main/codex/codex-session-backfill-audit.ts:json-stringify", + "src/main/codex/codex-session-backfill-marker.ts:json-parse", + "src/main/codex/codex-session-backfill-marker.ts:json-stringify", + "src/main/codex/codex-session-backfill-marker.ts:read-file", + "src/main/codex/codex-session-bridge.ts:json-parse", + "src/main/codex/codex-session-bridge.ts:read-file", + "src/main/codex/codex-session-index-heal-state.ts:json-parse", + "src/main/codex/codex-session-index-heal-state.ts:json-stringify", + "src/main/codex/codex-session-index-heal-state.ts:read-file", + "src/main/codex/codex-trust-config-rollback.ts:read-file", + "src/main/codex/codex-trust-grant-ledger.ts:json-parse", + "src/main/codex/codex-trust-grant-ledger.ts:json-stringify", + "src/main/codex/codex-trust-grant-ledger.ts:read-file", + "src/main/codex/config-settings-baseline.ts:json-stringify", + "src/main/codex/config-toml-trust.ts:json-stringify", + "src/main/codex/config-toml-trust.ts:read-file", + "src/main/codex/hook-service.ts:json-stringify", + "src/main/codex/hook-service.ts:read-file", + "src/main/codex/hook-trust-promotion.ts:json-parse", + "src/main/codex/hook-trust-promotion.ts:json-stringify", + "src/main/codex/hook-trust-promotion.ts:read-file", + "src/main/codex/wsl-codex-session-bridge.ts:json-parse", + "src/main/computer/desktop-script-action.ts:json-stringify", + "src/main/computer/desktop-script-provider-client.ts:json-parse", + "src/main/computer/desktop-script-provider-client.ts:json-stringify", + "src/main/computer/desktop-script-provider-test-harness.ts:json-parse", + "src/main/computer/desktop-script-provider-test-harness.ts:json-stringify", + "src/main/computer/macos-computer-use-permission-status.ts:json-parse", + "src/main/computer/macos-computer-use-permission-status.ts:read-file", + "src/main/computer/macos-native-provider-client.ts:json-parse", + "src/main/computer/macos-native-provider-client.ts:json-stringify", + "src/main/crash-reporting/crash-report-store.ts:json-parse", + "src/main/crash-reporting/crash-report-store.ts:json-stringify", + "src/main/crash-reporting/crash-report-store.ts:read-file", + "src/main/daemon/client.ts:json-parse", + "src/main/daemon/client.ts:read-file", + "src/main/daemon/daemon-entry.ts:read-file", + "src/main/daemon/daemon-file-log.ts:json-stringify", + "src/main/daemon/daemon-health.ts:json-parse", + "src/main/daemon/daemon-health.ts:read-file", + "src/main/daemon/daemon-host-relocation.ts:json-parse", + "src/main/daemon/daemon-host-relocation.ts:json-stringify", + "src/main/daemon/daemon-host-relocation.ts:read-file", + "src/main/daemon/daemon-init.ts:read-file", + "src/main/daemon/daemon-pty-adapter.ts:json-stringify", + "src/main/daemon/daemon-spawner.ts:json-parse", + "src/main/daemon/daemon-spawner.ts:json-stringify", + "src/main/daemon/daemon-spawner.ts:read-file", + "src/main/daemon/daemon-stream-backlog-probe.ts:json-stringify", + "src/main/daemon/history-manager.ts:json-parse", + "src/main/daemon/history-manager.ts:json-stringify", + "src/main/daemon/history-manager.ts:read-file", + "src/main/daemon/ndjson.ts:json-parse", + "src/main/daemon/ndjson.ts:json-stringify", + "src/main/daemon/terminal-history-file-reader.ts:json-parse", + "src/main/devin/hook-config-json.ts:read-file", + "src/main/devin/hook-settings.ts:json-stringify", + "src/main/diagnostics/main-thread-churn-probe.ts:json-stringify", + "src/main/emulator/emulator-probe.ts:json-stringify", + "src/main/emulator/serve-sim-accessibility-tree.ts:json-parse", + "src/main/emulator/serve-sim-accessibility-tree.ts:response-text", + "src/main/emulator/serve-sim-detached-session.ts:json-parse", + "src/main/emulator/serve-sim-detached-session.ts:read-file", + "src/main/emulator/serve-sim-execution.ts:json-parse", + "src/main/emulator/serve-sim-execution.ts:read-file", + "src/main/emulator/simctl-simulator-devices.ts:json-parse", + "src/main/ghostty/index.ts:json-stringify", + "src/main/ghostty/index.ts:read-file", + "src/main/ghostty/theme-resolution.ts:read-file", + "src/main/git/huge-folder-ignore.ts:read-file", + "src/main/git/repo.ts:read-file", + "src/main/git/status.ts:read-file", + "src/main/git/worktree-include-file.ts:read-file", + "src/main/git/worktree.ts:read-file", + "src/main/gitea/client.ts:response-text", + "src/main/gitea/pull-request-creation.ts:json-stringify", + "src/main/github/client.ts:json-parse", + "src/main/github/client.ts:read-file", + "src/main/github/conflict-summary-cache.ts:json-stringify", + "src/main/github/issues.ts:json-parse", + "src/main/github/local-git-config-signature.ts:read-file", + "src/main/github/merged-pr-commit-membership.ts:json-parse", + "src/main/github/project-view.ts:json-parse", + "src/main/github/project-view/internals.ts:json-parse", + "src/main/github/project-view/mutations.ts:json-parse", + "src/main/github/project-view/project-error-classification.ts:json-parse", + "src/main/github/rate-limit.ts:json-parse", + "src/main/github/work-item-details.ts:json-parse", + "src/main/github/work-item-details.ts:json-stringify", + "src/main/gitlab/client.ts:json-parse", + "src/main/gitlab/issues.ts:json-parse", + "src/main/gitlab/merge-request-creation-lookup.ts:json-parse", + "src/main/gitlab/merge-request-creation-lookup.ts:json-stringify", + "src/main/gitlab/merge-request-creation.ts:read-file", + "src/main/gitlab/work-item-details.ts:json-parse", + "src/main/hermes/hook-service.ts:json-stringify", + "src/main/hermes/hook-service.ts:read-file", + "src/main/hooks.ts:read-file", + "src/main/ipc/ai-vault.ts:json-stringify", + "src/main/ipc/app.ts:json-parse", + "src/main/ipc/crash-reporting.ts:json-stringify", + "src/main/ipc/developer-permissions.ts:unbounded-fanout", + "src/main/ipc/ephemeral-vm-runtime-handlers.ts:json-stringify", + "src/main/ipc/feedback.ts:json-stringify", + "src/main/ipc/filesystem-mutations.ts:read-file", + "src/main/ipc/filesystem-watcher.ts:json-stringify", + "src/main/ipc/filesystem.ts:read-file", + "src/main/ipc/notebook.ts:json-stringify", + "src/main/ipc/notification-authorization-status.ts:json-parse", + "src/main/ipc/notifications.ts:read-file", + "src/main/ipc/pet.ts:json-parse", + "src/main/ipc/pet.ts:read-file", + "src/main/ipc/repos.ts:read-file", + "src/main/ipc/shell.ts:read-file", + "src/main/ipc/ssh.ts:json-stringify", + "src/main/ipc/terminal-render-desync-evidence.ts:json-stringify", + "src/main/ipc/watcher-removal-gate.ts:json-stringify", + "src/main/ipc/workspace-cleanup-activity.ts:read-file", + "src/main/ipc/workspace-ports.ts:json-stringify", + "src/main/ipc/worktree-base-directory-poller.ts:unbounded-fanout", + "src/main/ipc/worktree-base-directory-watch-targets.ts:read-file", + "src/main/ipc/worktree-common-git-directory.ts:read-file", + "src/main/ipc/worktree-head-identity-reader.ts:read-file", + "src/main/ipc/worktree-remote.ts:read-file", + "src/main/ipc/worktrees.ts:read-file", + "src/main/jira/client.ts:json-parse", + "src/main/jira/client.ts:json-stringify", + "src/main/jira/client.ts:read-file", + "src/main/jira/client.ts:response-text", + "src/main/jira/issues.ts:json-stringify", + "src/main/keybindings/keybinding-file.ts:json-parse", + "src/main/keybindings/keybinding-file.ts:json-stringify", + "src/main/keybindings/keybinding-file.ts:read-file", + "src/main/kimi/hook-service.ts:read-file", + "src/main/linear/client.ts:json-parse", + "src/main/linear/client.ts:json-stringify", + "src/main/linear/client.ts:read-file", + "src/main/linear/issues.ts:unbounded-fanout", + "src/main/local-worktree-filesystem.ts:read-file", + "src/main/minimax/minimax-cookie-store.ts:read-file", + "src/main/native-chat/transcript-line-decoders-grok.ts:json-parse", + "src/main/native-chat/transcript-record-blocks.ts:json-stringify", + "src/main/observability/bundle.ts:json-parse", + "src/main/observability/bundle.ts:json-stringify", + "src/main/observability/bundle.ts:read-file", + "src/main/observability/diagnostic-upload-http.ts:json-parse", + "src/main/observability/diagnostic-upload-http.ts:json-stringify", + "src/main/observability/local-file-sink.ts:json-stringify", + "src/main/opencode-usage/scanner.ts:json-parse", + "src/main/opencode-usage/scanner.ts:json-stringify", + "src/main/opencode-usage/store.ts:json-parse", + "src/main/opencode-usage/store.ts:json-stringify", + "src/main/opencode-usage/store.ts:read-file", + "src/main/opencode/hook-service.ts:json-parse", + "src/main/opencode/hook-service.ts:json-stringify", + "src/main/opencode/hook-service.ts:read-file", + "src/main/orca-profiles/profile-cloud-client.ts:json-stringify", + "src/main/orca-profiles/profile-cloud-client.ts:response-text", + "src/main/orca-profiles/profile-cloud-org-members-client.ts:json-stringify", + "src/main/orca-profiles/profile-cloud-org-members-client.ts:response-text", + "src/main/orca-profiles/profile-cloud-session-mutation.ts:json-parse", + "src/main/orca-profiles/profile-cloud-session-mutation.ts:read-file", + "src/main/orca-profiles/profile-cloud-session-store.ts:json-parse", + "src/main/orca-profiles/profile-cloud-session-store.ts:json-stringify", + "src/main/orca-profiles/profile-cloud-session-store.ts:read-file", + "src/main/orca-profiles/profile-index-store.ts:json-parse", + "src/main/orca-profiles/profile-index-store.ts:json-stringify", + "src/main/orca-profiles/profile-index-store.ts:read-file", + "src/main/orca-profiles/profile-project-state-file.ts:json-parse", + "src/main/orca-profiles/profile-project-state-file.ts:json-stringify", + "src/main/orca-profiles/profile-project-state-file.ts:read-file", + "src/main/persistence.ts:json-parse", + "src/main/persistence.ts:json-stringify", + "src/main/persistence.ts:read-file", + "src/main/pi/agent-status-extension-source.ts:json-stringify", + "src/main/pi/agent-status-extension-source.ts:read-file", + "src/main/pi/titlebar-extension-service.ts:read-file", + "src/main/ports/local-workspace-port-scanner.ts:json-parse", + "src/main/ports/local-workspace-port-scanner.ts:read-file", + "src/main/project-groups/nested-repo-discovery.ts:read-file", + "src/main/providers/ssh-filesystem-provider.ts:read-file", + "src/main/providers/ssh-git-provider.ts:json-stringify", + "src/main/providers/types.ts:read-file", + "src/main/providers/windows-foreground-process-rows.ts:json-parse", + "src/main/pty/shell-startup-env.ts:read-file", + "src/main/rate-limits/claude-fetcher.ts:json-parse", + "src/main/rate-limits/claude-fetcher.ts:read-file", + "src/main/rate-limits/claude-fetcher.ts:response-text", + "src/main/rate-limits/claude-oauth-usage-error.ts:response-text", + "src/main/rate-limits/codex-fetcher.ts:json-parse", + "src/main/rate-limits/codex-fetcher.ts:json-stringify", + "src/main/rate-limits/codex-fetcher.ts:read-file", + "src/main/rate-limits/codex-fetcher.ts:response-text", + "src/main/rate-limits/gemini-cli-oauth-extractor.ts:json-parse", + "src/main/rate-limits/gemini-cli-oauth-extractor.ts:read-file", + "src/main/rate-limits/gemini-oauth-sources.ts:json-parse", + "src/main/rate-limits/gemini-oauth-sources.ts:json-stringify", + "src/main/rate-limits/gemini-oauth-sources.ts:read-file", + "src/main/rate-limits/gemini-oauth-sources.ts:response-text", + "src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts:json-stringify", + "src/main/rate-limits/gemini-usage-fetcher.ts:json-stringify", + "src/main/rate-limits/gemini-usage-fetcher.ts:response-text", + "src/main/rate-limits/grok-auth.ts:json-parse", + "src/main/rate-limits/grok-auth.ts:read-file", + "src/main/rate-limits/grok-fetcher.ts:response-text", + "src/main/rate-limits/kimi-fetcher.ts:json-parse", + "src/main/rate-limits/kimi-fetcher.ts:read-file", + "src/main/rate-limits/kimi-fetcher.ts:response-text", + "src/main/rate-limits/minimax-fetcher.ts:response-text", + "src/main/rate-limits/opencode-go-usage-fetcher.ts:response-text", + "src/main/remote-agent-trust-presets.ts:json-parse", + "src/main/remote-agent-trust-presets.ts:json-stringify", + "src/main/remote-agent-trust-presets.ts:read-file", + "src/main/repo-icon-autodetect.ts:json-parse", + "src/main/repo-icon-autodetect.ts:read-file", + "src/main/runtime/agent-session-claim-identity.ts:read-file", + "src/main/runtime/claude-agent-teams-shim-env.ts:read-file", + "src/main/runtime/device-registry.ts:json-parse", + "src/main/runtime/device-registry.ts:read-file", + "src/main/runtime/e2ee-keypair.ts:json-parse", + "src/main/runtime/e2ee-keypair.ts:read-file", + "src/main/runtime/orca-runtime-files.ts:json-stringify", + "src/main/runtime/orca-runtime-files.ts:read-file", + "src/main/runtime/orca-runtime.ts:json-stringify", + "src/main/runtime/orca-runtime.ts:read-file", + "src/main/runtime/orchestration/coordinator.ts:json-parse", + "src/main/runtime/orchestration/db.ts:json-parse", + "src/main/runtime/orchestration/db.ts:json-stringify", + "src/main/runtime/orchestration/lifecycle-reconciliation.ts:json-parse", + "src/main/runtime/orchestration/lifecycle-reconciliation.ts:json-stringify", + "src/main/runtime/relay/relay-control-client.ts:json-stringify", + "src/main/runtime/relay/relay-control-protocol.ts:json-parse", + "src/main/runtime/relay/relay-http-client.ts:json-stringify", + "src/main/runtime/relay/relay-http-client.ts:response-text", + "src/main/runtime/relay/relay-revoke-outbox.ts:json-parse", + "src/main/runtime/relay/relay-revoke-outbox.ts:read-file", + "src/main/runtime/rpc/dispatcher.ts:json-stringify", + "src/main/runtime/rpc/e2ee-channel.ts:json-stringify", + "src/main/runtime/rpc/methods/orchestration-gates.ts:json-parse", + "src/main/runtime/rpc/methods/orchestration.ts:json-parse", + "src/main/runtime/rpc/methods/orchestration.ts:json-stringify", + "src/main/runtime/rpc/relay-transport.ts:json-stringify", + "src/main/runtime/rpc/ws-fallback-port-store.ts:json-parse", + "src/main/runtime/rpc/ws-fallback-port-store.ts:json-stringify", + "src/main/runtime/rpc/ws-fallback-port-store.ts:read-file", + "src/main/runtime/runtime-metadata.ts:json-parse", + "src/main/runtime/runtime-metadata.ts:read-file", + "src/main/runtime/runtime-rpc.ts:json-parse", + "src/main/runtime/runtime-rpc.ts:json-stringify", + "src/main/runtime/runtime-rpc.ts:unbounded-fanout", + "src/main/runtime/tls-certificate.ts:read-file", + "src/main/runtime/windows-mobile-firewall.ts:json-parse", + "src/main/serve-update-handoff.ts:json-parse", + "src/main/serve-update-handoff.ts:json-stringify", + "src/main/serve-update-handoff.ts:read-file", + "src/main/server/serve-readiness.ts:json-stringify", + "src/main/skills/claude-plugin-skill-sources.ts:json-parse", + "src/main/skills/skill-bundle-artifacts.ts:read-file", + "src/main/skills/skill-package-identity.ts:json-stringify", + "src/main/source-control/hosted-review-api-request.ts:response-text", + "src/main/source-control/pull-request-template.ts:read-file", + "src/main/speech/openai-api-key-store.ts:json-parse", + "src/main/speech/openai-api-key-store.ts:read-file", + "src/main/speech/openai-transcription-client.ts:response-text", + "src/main/speech/stt-worker.ts:json-parse", + "src/main/ssh/relay-protocol.ts:json-parse", + "src/main/ssh/relay-protocol.ts:json-stringify", + "src/main/ssh/ssh-agent-identity-filter.ts:read-file", + "src/main/ssh/ssh-auth-resolution.ts:read-file", + "src/main/ssh/ssh-config-include-expander.ts:read-file", + "src/main/ssh/ssh-control-socket.ts:json-stringify", + "src/main/ssh/ssh-git-response-stream-reader.ts:json-parse", + "src/main/ssh/ssh-port-forward.ts:unbounded-fanout", + "src/main/ssh/ssh-relay-deploy.ts:json-stringify", + "src/main/ssh/ssh-relay-versioned-install.ts:read-file", + "src/main/ssh/ssh-remote-cli-format.ts:json-stringify", + "src/main/ssh/ssh-remote-commands.ts:read-file", + "src/main/ssh/ssh-remote-orca-cli.ts:json-stringify", + "src/main/ssh/ssh-remote-orchestration-send.ts:json-stringify", + "src/main/ssh/ssh-remote-platform.ts:json-stringify", + "src/main/ssh/system-ssh-file-transfer.ts:json-stringify", + "src/main/ssh/system-ssh-file-transfer.ts:read-file", + "src/main/startup/configure-process.ts:json-parse", + "src/main/startup/configure-process.ts:read-file", + "src/main/startup/ensure-virtual-display.ts:read-file", + "src/main/startup/gpu-fallback-marker.ts:json-parse", + "src/main/startup/gpu-fallback-marker.ts:json-stringify", + "src/main/startup/gpu-fallback-marker.ts:read-file", + "src/main/startup/startup-diagnostics.ts:json-stringify", + "src/main/startup/windows-user-data-acl.ts:json-parse", + "src/main/startup/windows-user-data-acl.ts:json-stringify", + "src/main/startup/windows-user-data-acl.ts:read-file", + "src/main/stats/collector.ts:json-parse", + "src/main/stats/collector.ts:json-stringify", + "src/main/stats/collector.ts:read-file", + "src/main/system-fonts.ts:json-parse", + "src/main/telemetry/consent.ts:json-stringify", + "src/main/terminal-history.ts:json-parse", + "src/main/terminal-history.ts:json-stringify", + "src/main/terminal-history.ts:read-file", + "src/main/updater-changelog.ts:response-text", + "src/main/updater-lifecycle-diagnostics.ts:json-stringify", + "src/main/updater-nudge.ts:response-text", + "src/main/updater-prerelease-feed.ts:response-text", + "src/main/warp-themes/index.ts:read-file", + "src/main/workspace-space-analysis.ts:unbounded-fanout", + "src/main/worktree-removal-safety.ts:read-file", + "src/relay/agent-exec-handler.ts:json-stringify", + "src/relay/external-automations-handler.ts:json-parse", + "src/relay/external-automations-handler.ts:read-file", + "src/relay/fs-handler-file-read.ts:read-file", + "src/relay/fs-handler.ts:json-stringify", + "src/relay/fs-handler.ts:read-file", + "src/relay/fs-stream-registry.ts:unbounded-fanout", + "src/relay/git-handler-status-ops.ts:read-file", + "src/relay/git-handler.ts:json-stringify", + "src/relay/git-working-file-read.ts:read-file", + "src/relay/plugin-overlay.ts:read-file", + "src/relay/port-scan-handler.ts:read-file", + "src/relay/protocol.ts:json-parse", + "src/relay/protocol.ts:json-stringify", + "src/relay/pty-handler.ts:json-parse", + "src/relay/pty-handler.ts:json-stringify", + "src/relay/pty-handler.ts:unbounded-fanout", + "src/relay/pty-shell-launch.ts:read-file", + "src/relay/pty-shell-utils.ts:read-file", + "src/relay/relay-handshake.ts:read-file", + "src/relay/relay-watcher-removal-fence.ts:unbounded-fanout", + "src/relay/windows-port-scan.ts:json-parse", + "src/relay/workspace-session-handler.ts:json-parse", + "src/relay/workspace-session-handler.ts:json-stringify", + "src/relay/workspace-session-handler.ts:read-file", + "src/relay/wsl-hook-fs-bridge.ts:read-file", + "src/renderer/src/components/GitHubItemDialog.tsx:json-stringify", + "src/renderer/src/components/PullRequestPage.tsx:json-stringify", + "src/renderer/src/components/TaskPage.tsx:json-parse", + "src/renderer/src/components/TaskPage.tsx:json-stringify", + "src/renderer/src/components/Terminal.tsx:unbounded-fanout", + "src/renderer/src/components/automations/AutomationsPage.tsx:json-stringify", + "src/renderer/src/components/browser-pane/BrowserPane.tsx:json-parse", + "src/renderer/src/components/browser-pane/BrowserPane.tsx:json-stringify", + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx:json-stringify", + "src/renderer/src/components/editor/CombinedDiffViewer.tsx:json-stringify", + "src/renderer/src/components/editor/IpynbViewer.tsx:json-stringify", + "src/renderer/src/components/editor/editor-autosave-controller.ts:unbounded-fanout", + "src/renderer/src/components/editor/ipynb-parse.ts:json-parse", + "src/renderer/src/components/editor/ipynb-parse.ts:json-stringify", + "src/renderer/src/components/editor/markdown-document-list-request.ts:json-stringify", + "src/renderer/src/components/editor/markdown-export-extract.ts:response-text", + "src/renderer/src/components/editor/raw-markdown-html.ts:json-stringify", + "src/renderer/src/components/editor/rich-markdown-html-superscript-link.ts:json-parse", + "src/renderer/src/components/editor/useEditorPanelContentState.ts:json-stringify", + "src/renderer/src/components/editor/useLocalImageSrc.ts:read-file", + "src/renderer/src/components/feature-wall/feature-wall-completion-persistence.ts:json-parse", + "src/renderer/src/components/feature-wall/feature-wall-completion-persistence.ts:json-stringify", + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:json-stringify", + "src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts:json-parse", + "src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts:json-stringify", + "src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts:json-parse", + "src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts:json-stringify", + "src/renderer/src/components/github-project/column-widths.ts:json-parse", + "src/renderer/src/components/github-project/column-widths.ts:json-stringify", + "src/renderer/src/components/github-project/columns.ts:json-parse", + "src/renderer/src/components/github-project/columns.ts:json-stringify", + "src/renderer/src/components/github-project/project-visible-table-cache.ts:json-stringify", + "src/renderer/src/components/landing-preflight-dismissal.ts:json-parse", + "src/renderer/src/components/landing-preflight-dismissal.ts:json-stringify", + "src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx:json-stringify", + "src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts:json-parse", + "src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts:json-stringify", + "src/renderer/src/components/native-chat/native-chat-session-assembler.ts:json-stringify", + "src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts:json-stringify", + "src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts:json-stringify", + "src/renderer/src/components/pet/PetOverlay.tsx:json-parse", + "src/renderer/src/components/pet/PetOverlay.tsx:json-stringify", + "src/renderer/src/components/pr-comments-resolution-prompt.ts:json-stringify", + "src/renderer/src/components/quick-open-file-list.ts:json-stringify", + "src/renderer/src/components/right-sidebar/ChecksPanel.tsx:json-stringify", + "src/renderer/src/components/right-sidebar/PortsPanel.tsx:json-stringify", + "src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx:json-stringify", + "src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts:json-parse", + "src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts:json-stringify", + "src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts:json-stringify", + "src/renderer/src/components/right-sidebar/checks-panel-polling.ts:json-stringify", + "src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts:json-stringify", + "src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts:json-stringify", + "src/renderer/src/components/right-sidebar/useFileDeletion.ts:unbounded-fanout", + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts:json-stringify", + "src/renderer/src/components/right-sidebar/useGitHistoryCommitActions.ts:json-stringify", + "src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts:json-stringify", + "src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx:json-stringify", + "src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx:unbounded-fanout", + "src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx:json-stringify", + "src/renderer/src/components/settings/mcp-config-inspection.ts:read-file", + "src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts:json-stringify", + "src/renderer/src/components/settings/repository-source-control-ai-persist-queue.ts:json-stringify", + "src/renderer/src/components/settings/source-control-action-recipe-draft-state.ts:json-stringify", + "src/renderer/src/components/settings/source-control-ai-action-recipe-draft.ts:json-stringify", + "src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts:json-stringify", + "src/renderer/src/components/sidebar/WorktreeList.tsx:unbounded-fanout", + "src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts:json-stringify", + "src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts:json-stringify", + "src/renderer/src/components/sidebar/workspace-status-drag-data.ts:json-parse", + "src/renderer/src/components/sidebar/workspace-status-drag-data.ts:json-stringify", + "src/renderer/src/components/status-bar/SshStatusSegment.tsx:unbounded-fanout", + "src/renderer/src/components/status-bar/StatusBar.tsx:json-stringify", + "src/renderer/src/components/task-page-cache-selectors.ts:json-stringify", + "src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts:json-stringify", + "src/renderer/src/hooks/useIpcEvents.ts:unbounded-fanout", + "src/renderer/src/lib/ai-vault-session-drag.ts:json-parse", + "src/renderer/src/lib/ai-vault-session-drag.ts:json-stringify", + "src/renderer/src/lib/app-font-family.ts:json-stringify", + "src/renderer/src/lib/execute-open-editor-path-move.ts:unbounded-fanout", + "src/renderer/src/lib/feature-education-telemetry.ts:json-parse", + "src/renderer/src/lib/feature-education-telemetry.ts:json-stringify", + "src/renderer/src/lib/react-error-boundary-reporting.ts:json-stringify", + "src/renderer/src/lib/workspace-file-drag.ts:json-parse", + "src/renderer/src/lib/workspace-file-drag.ts:json-stringify", + "src/renderer/src/lib/workspace-port-actions.ts:json-stringify", + "src/renderer/src/lib/worktree-operation-generation.ts:json-stringify", + "src/renderer/src/lib/worktree-operation-route.ts:json-stringify", + "src/renderer/src/lib/worktree-runtime-owner-index.ts:json-stringify", + "src/renderer/src/runtime/runtime-file-client.ts:read-file", + "src/renderer/src/runtime/sync-runtime-graph.ts:json-stringify", + "src/renderer/src/runtime/web-session-intent-owner.ts:json-stringify", + "src/renderer/src/store/slices/linear.ts:json-stringify", + "src/renderer/src/store/slices/pull-request-generation.ts:json-stringify", + "src/renderer/src/store/slices/repos.ts:json-stringify", + "src/renderer/src/store/slices/terminal-tab-retirement.ts:json-stringify", + "src/renderer/src/store/slices/workspace-cleanup.ts:json-stringify", + "src/renderer/src/web/web-pairing.ts:json-parse", + "src/renderer/src/web/web-preload-api.ts:json-parse", + "src/renderer/src/web/web-preload-api.ts:json-stringify", + "src/renderer/src/web/web-runtime-client.ts:json-parse", + "src/renderer/src/web/web-runtime-client.ts:json-stringify", + "src/renderer/src/web/web-runtime-client.ts:response-text", + "src/renderer/src/web/web-runtime-environment.ts:json-parse", + "src/renderer/src/web/web-runtime-environment.ts:json-stringify", + "src/shared/agent-hook-listener.ts:json-parse", + "src/shared/agent-hook-listener.ts:json-stringify", + "src/shared/agent-question-answered-intent.ts:json-parse", + "src/shared/agent-status-types.ts:json-parse", + "src/shared/browser-annotation-viewport-bridge.ts:json-stringify", + "src/shared/browser-screencast-protocol.ts:json-parse", + "src/shared/browser-screencast-protocol.ts:json-stringify", + "src/shared/claude-statusline-rate-limits.ts:json-parse", + "src/shared/codex-reset-credit-attempt-ledger.ts:json-stringify", + "src/shared/codex-reset-credit-scope.ts:json-stringify", + "src/shared/commit-message-agent-spec.ts:json-parse", + "src/shared/emulator-keyboard-frame.ts:json-stringify", + "src/shared/emulator-touch-frame.ts:json-stringify", + "src/shared/ephemeral-vm-recipe-lifecycle-payload.ts:json-stringify", + "src/shared/ephemeral-vm-recipe-runner.ts:json-stringify", + "src/shared/ephemeral-vm-recipes.ts:json-parse", + "src/shared/ephemeral-vm-runtime-store.ts:json-parse", + "src/shared/external-automation-jobs-file.ts:json-parse", + "src/shared/fetch-response-body.ts:json-parse", + "src/shared/fetch-response-body.ts:response-text", + "src/shared/git-effective-upstream.ts:json-stringify", + "src/shared/git-publish-target-status.ts:json-stringify", + "src/shared/git-status-line-stats-cache.ts:json-stringify", + "src/shared/in-flight-promise-dedupe.ts:json-stringify", + "src/shared/linear-issue-attribute-filter.ts:json-stringify", + "src/shared/map-with-concurrency.ts:unbounded-fanout", + "src/shared/mcp-config.ts:json-parse", + "src/shared/mobile-relay-pairing-fixtures.ts:json-stringify", + "src/shared/native-chat-ask.ts:json-parse", + "src/shared/native-chat-tool-summary.ts:json-stringify", + "src/shared/pairing.ts:json-parse", + "src/shared/pairing.ts:json-stringify", + "src/shared/pr-checks-fix-prompt.ts:json-stringify", + "src/shared/pull-request-generation.ts:json-parse", + "src/shared/remote-runtime-client.ts:json-stringify", + "src/shared/remote-runtime-request-frames.ts:json-parse", + "src/shared/remote-runtime-request-websocket.ts:json-stringify", + "src/shared/runtime-bootstrap.ts:json-parse", + "src/shared/runtime-environment-store.ts:json-parse", + "src/shared/secure-file.ts:json-stringify", + "src/shared/serve-update-handoff.ts:json-parse", + "src/shared/setup-script-import-codex-environment.ts:json-parse", + "src/shared/setup-script-import-codex-environment.ts:read-file", + "src/shared/setup-script-imports.ts:json-parse", + "src/shared/setup-script-imports.ts:read-file", + "src/shared/setup-script-package-manager-suggestion.ts:json-parse", + "src/shared/setup-script-package-manager-suggestion.ts:read-file", + "src/shared/source-control-ai.ts:json-stringify", + "src/shared/source-control-commit-failure.ts:json-stringify", + "src/shared/source-control-conflict-prompts.ts:json-stringify", + "src/shared/source-control-push-failure.ts:json-stringify", + "src/shared/terminal-stream-protocol.ts:json-parse", + "src/shared/terminal-stream-protocol.ts:json-stringify", + "src/shared/text-search.ts:json-parse" + ] +} diff --git a/config/scripts/check-bounded-ingress.mjs b/config/scripts/check-bounded-ingress.mjs new file mode 100644 index 00000000000..114721bf1fa --- /dev/null +++ b/config/scripts/check-bounded-ingress.mjs @@ -0,0 +1,158 @@ +// Ratchet: unbounded reads of untrusted/unpredictable input are the OOM crash class (#9872, #9984). +// Existing occurrences are frozen in a baseline; new ones must use a src/shared/memory-safety helper +// or carry a `bounded-by:` justification comment on the line or the line above. +// +// Run: node config/scripts/check-bounded-ingress.mjs [--update-baseline] +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']) +const SKIP_PATH_PARTS = new Set([ + 'node_modules', + 'dist', + 'out', + '.git', + '__snapshots__', + 'memory-safety' +]) +const BASELINE_PATH = 'config/bounded-ingress-baseline.json' + +// Each pattern names an unbounded way to pull data into memory. +const PATTERNS = [ + { + id: 'read-file', + re: /\breadFile(?:Sync)?\s*\(/, + hint: 'use readNodeFileWithinLimit (memory-safety/node-bounded-file-reader)' + }, + { + id: 'response-text', + re: /\.(?:text|json|arrayBuffer|blob)\s*\(\s*\)/, + hint: 'bound the response body before materializing it' + }, + { + id: 'json-parse', + re: /\bJSON\.parse\s*\(/, + hint: 'guard with assertJsonTextWithinStructureLimits (memory-safety/json-text-structure-limit)' + }, + { + id: 'json-stringify', + re: /\bJSON\.stringify\s*\(/, + hint: 'use stringifyJsonWithinLimit (memory-safety/node-bounded-json-stringify) when the value can be large' + }, + { + id: 'unbounded-fanout', + re: /Promise\.all\s*\(\s*[\w.]+\s*\.map\s*\(/, + hint: 'use mapWithConcurrency so fan-out cannot grow with input size' + } +] + +const ALLOW_RE = /bounded-by:/i + +export function findViolations(relativePath, source) { + const lines = source.split('\n') + const found = [] + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] + if (ALLOW_RE.test(line) || (i > 0 && ALLOW_RE.test(lines[i - 1]))) { + continue + } + for (const pattern of PATTERNS) { + if (pattern.re.test(line)) { + found.push({ file: relativePath, line: i + 1, id: pattern.id, hint: pattern.hint }) + } + } + } + return found +} + +export function violationKey(violation) { + return `${violation.file}:${violation.id}` +} + +function isSkipped(relative) { + if (relative.includes('.test.') || relative.includes('.spec.') || relative.includes('.bench.')) { + return true + } + return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part)) +} + +async function collect(root, dir, out = []) { + let entries + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return out + } + for (const entry of entries) { + const full = path.join(dir, entry.name) + const relative = path.relative(root, full).split(path.sep).join('/') + if (entry.isDirectory()) { + if (!SKIP_PATH_PARTS.has(entry.name)) { + await collect(root, full, out) + } + } else if ( + entry.isFile() && + SOURCE_EXTENSIONS.has(path.extname(entry.name)) && + !isSkipped(relative) + ) { + out.push({ full, relative }) + } + } + return out +} + +export async function run(root, { updateBaseline = false } = {}) { + const files = [ + ...(await collect(root, path.join(root, 'src'))), + ...(await collect(root, path.join(root, 'mobile/src'))) + ] + const violations = [] + for (const file of files) { + const source = await fs.readFile(file.full, 'utf8') + violations.push(...findViolations(file.relative, source)) + } + + const baselineFile = path.join(root, BASELINE_PATH) + if (updateBaseline) { + const keys = [...new Set(violations.map(violationKey))].sort() + await fs.writeFile(baselineFile, `${JSON.stringify({ allowed: keys }, null, 2)}\n`) + return { added: [], baselineSize: keys.length, updated: true } + } + + let allowed = new Set() + try { + allowed = new Set(JSON.parse(await fs.readFile(baselineFile, 'utf8')).allowed) + } catch { + // No baseline yet: every occurrence is reported so the first run can seed one. + } + const added = violations.filter((v) => !allowed.has(violationKey(v))) + return { added, baselineSize: allowed.size, updated: false } +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (invokedDirectly) { + const root = path.resolve(import.meta.dirname, '../..') + const updateBaseline = process.argv.includes('--update-baseline') + const { added, baselineSize, updated } = await run(root, { updateBaseline }) + if (updated) { + console.log(`bounded-ingress baseline updated (${baselineSize} allowed entries)`) + process.exit(0) + } + if (added.length > 0) { + console.error('Unbounded ingress introduced in files not covered by the baseline:\n') + for (const v of added.slice(0, 40)) { + console.error(` ${v.file}:${v.line} [${v.id}]\n ${v.hint}`) + } + if (added.length > 40) { + console.error(` ... and ${added.length - 40} more`) + } + console.error( + '\nSee src/shared/memory-safety/README.md. If the size is genuinely bounded already,' + + ' add a `bounded-by:` comment on or above the line explaining what bounds it.' + ) + process.exit(1) + } + console.log(`bounded-ingress: no new unbounded reads (${baselineSize} baseline entries)`) +} diff --git a/config/scripts/check-bounded-ingress.test.mjs b/config/scripts/check-bounded-ingress.test.mjs new file mode 100644 index 00000000000..0f044e249b5 --- /dev/null +++ b/config/scripts/check-bounded-ingress.test.mjs @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { findViolations, violationKey } from './check-bounded-ingress.mjs' + +describe('check-bounded-ingress', () => { + it('flags an unbounded file read and JSON parse', () => { + const found = findViolations( + 'src/main/thing.ts', + ['const buf = await readFile(p)', 'return JSON.parse(buf.toString())'].join('\n') + ) + expect(found.map((v) => v.id)).toEqual(['read-file', 'json-parse']) + expect(found[0].line).toBe(1) + }) + + it('honors a bounded-by justification on the line and the line above', () => { + const sameLine = findViolations( + 'src/main/thing.ts', + 'const buf = await readFile(p) // bounded-by: fixture path under 1KB' + ) + const lineAbove = findViolations( + 'src/main/thing.ts', + ['// bounded-by: size checked by the caller', 'const buf = await readFile(p)'].join('\n') + ) + expect(sameLine).toEqual([]) + expect(lineAbove).toEqual([]) + }) + + it('flags unbounded response bodies and fan-out', () => { + const found = findViolations( + 'src/main/thing.ts', + ['const body = await response.text()', 'await Promise.all(items.map((i) => load(i)))'].join( + '\n' + ) + ) + expect(found.map((v) => v.id)).toEqual(['response-text', 'unbounded-fanout']) + }) + + it('keys violations by file and rule so a baseline survives line moves', () => { + expect(violationKey({ file: 'src/a.ts', id: 'read-file', line: 12 })).toBe('src/a.ts:read-file') + expect(violationKey({ file: 'src/a.ts', id: 'read-file', line: 99 })).toBe('src/a.ts:read-file') + }) + + it('ignores lines with no ingress pattern', () => { + expect(findViolations('src/main/thing.ts', 'const total = a + b')).toEqual([]) + }) +}) diff --git a/package.json b/package.json index 0c596dbf8a0..44b3ecbf608 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:bounded-ingress && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -104,7 +104,8 @@ "bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs", "bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-reveal-bench.mjs", "bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-resource-bench.mjs", - "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs" + "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs", + "check:bounded-ingress": "node config/scripts/check-bounded-ingress.mjs" }, "dependencies": { "@electron-toolkit/preload": "^3.0.2", diff --git a/src/main/agent-state-file-reader.test.ts b/src/main/agent-state-file-reader.test.ts index 34638fcbc06..66d39d07510 100644 --- a/src/main/agent-state-file-reader.test.ts +++ b/src/main/agent-state-file-reader.test.ts @@ -2,7 +2,7 @@ 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 { NodeFileReadTooLargeError } from '../shared/node-bounded-file-reader' +import { NodeFileReadTooLargeError } from '../shared/memory-safety/node-bounded-file-reader' import { MAX_AGENT_STATE_FILE_BYTES, MAX_AGENT_STATE_JSON_NESTING_DEPTH, diff --git a/src/main/agent-state-file-reader.ts b/src/main/agent-state-file-reader.ts index 0980fb5f935..1448de55f5c 100644 --- a/src/main/agent-state-file-reader.ts +++ b/src/main/agent-state-file-reader.ts @@ -1,5 +1,5 @@ -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' -import { assertJsonTextStructureWithinLimits } from '../shared/json-text-structure-limit' +import { readNodeFileSyncWithinLimit } from '../shared/memory-safety/node-bounded-file-reader' +import { assertJsonTextStructureWithinLimits } from '../shared/memory-safety/json-text-structure-limit' export const MAX_AGENT_STATE_FILE_BYTES = 4 * 1024 * 1024 export const MAX_AGENT_STATE_JSON_STRUCTURAL_TOKENS = 1_000_000 diff --git a/src/main/daemon/terminal-history-file-reader.ts b/src/main/daemon/terminal-history-file-reader.ts index 237eca57bdd..4a67715fc46 100644 --- a/src/main/daemon/terminal-history-file-reader.ts +++ b/src/main/daemon/terminal-history-file-reader.ts @@ -1,7 +1,7 @@ import { readNodeFileSyncWithinLimit, readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' +} from '../../shared/memory-safety/node-bounded-file-reader' export function readTerminalHistoryBuffer(filePath: string, maxBytes: number): Buffer { return readNodeFileSyncWithinLimit(filePath, maxBytes).buffer diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 2cdc41dc367..e6b0795f0aa 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: git status/discard/chunking behavior is verified together here to keep the command contract readable in one place. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFs from 'node:fs' -import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' +import type * as BoundedFileReader from '../../shared/memory-safety/node-bounded-file-reader' import path from 'node:path' import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS, @@ -63,7 +63,7 @@ vi.mock('fs', () => ({ existsSync: existsSyncMock })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => { +vi.mock('../../shared/memory-safety/node-bounded-file-reader', async (importOriginal) => { const actual = await importOriginal() return { ...actual, diff --git a/src/main/github/conflict-summary-cache.ts b/src/main/github/conflict-summary-cache.ts index 99a2715ce6d..e20c51efe5f 100644 --- a/src/main/github/conflict-summary-cache.ts +++ b/src/main/github/conflict-summary-cache.ts @@ -1,4 +1,5 @@ import type { PRConflictSummary } from '../../shared/types' +import { BoundedMap } from '../../shared/memory-safety/bounded-map' // Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR // (10s mergeability-pending, 2.5s manual-pending) previously each ran a @@ -31,8 +32,10 @@ type CachedSummary = { staleAt: number | null } -const baseOidCache = new Map() -const summaryCache = new Map() +const baseOidCache = new BoundedMap({ + maxEntries: BASE_OID_CACHE_MAX +}) +const summaryCache = new BoundedMap({ maxEntries: SUMMARY_CACHE_MAX }) const inFlightBaseOidResolves = new Map>() const inFlightSummaryDerivations = new Map>() @@ -62,16 +65,11 @@ export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolut } export function storeResolvedBaseTip(baseKey: string, oid: string): void { - setBoundedMapEntry(baseOidCache, baseKey, { oid, resolvedAt: Date.now() }, BASE_OID_CACHE_MAX) + baseOidCache.set(baseKey, { oid, resolvedAt: Date.now() }) } export function rememberUnresolvedBaseTip(baseKey: string): void { - setBoundedMapEntry( - baseOidCache, - baseKey, - { oid: null, resolvedAt: Date.now() }, - BASE_OID_CACHE_MAX - ) + baseOidCache.set(baseKey, { oid: null, resolvedAt: Date.now() }) } export function readCachedSummary(summaryKey: string): CachedSummary | null { @@ -87,15 +85,10 @@ export function readCachedSummary(summaryKey: string): CachedSummary | null { } export function storeCachedSummary(summaryKey: string, value: PRConflictSummary | undefined): void { - setBoundedMapEntry( - summaryCache, - summaryKey, - { - value, - staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null - }, - SUMMARY_CACHE_MAX - ) + summaryCache.set(summaryKey, { + value, + staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null + }) } export function dedupeBaseOidResolve( @@ -128,20 +121,6 @@ function dedupeInFlight( return promise } -function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { - if (map.has(key)) { - map.delete(key) - } - map.set(key, value) - while (map.size > maxEntries) { - const oldest = map.keys().next() - if (oldest.done) { - return - } - map.delete(oldest.value) - } -} - export function __resetPRConflictSummaryDerivationCachesForTests(): void { baseOidCache.clear() summaryCache.clear() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 48920729504..51c2223dffe 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10,6 +10,7 @@ import { normalizeTerminalTitle } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' +import { BoundedMap } from '../../shared/memory-safety/bounded-map' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' import { parseFileUriPathParts } from '../daemon/osc7-file-uri' import type { AgentStatus } from '../../shared/agent-detection' @@ -2886,13 +2887,17 @@ export class OrcaRuntimeService { // same repo remote/ref locks. This queue serializes all fetch shapes for one // canonical repo+remote while still letting same-shape callers share promises. private remoteFetchQueueTail = new Map>() - private fetchLastCompletedAt = new Map() + private fetchLastCompletedAt = new BoundedMap({ + maxEntries: REMOTE_FETCH_CACHE_MAX + }) // Why: `getCanonicalFetchKey` is awaited from every freshness probe and // every getOrStartRemoteFetch call. Without memoization the warm-cache hot // path spawns a `git rev-parse --git-common-dir` subprocess per touch // (twice in createLocalWorktree). Cache by `::` so the // canonical key is resolved at most once per repo+remote in the process. - private canonicalFetchKeyCache = new Map() + private canonicalFetchKeyCache = new BoundedMap({ + maxEntries: REMOTE_FETCH_CACHE_MAX + }) private optimisticReconcileTokens = new Map() private removeManagedWorktreeInFlight = new Map() private preservedBranchCleanupByWorktreeId = new Map() @@ -19530,7 +19535,7 @@ export class OrcaRuntimeService { const cacheKey = `${runtimeKey}::${repoPath}::${remote}` const cached = this.canonicalFetchKeyCache.get(cacheKey) if (cached !== undefined) { - setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, cached, REMOTE_FETCH_CACHE_MAX) + this.canonicalFetchKeyCache.set(cacheKey, cached) return cached } let resolved = cacheKey @@ -19547,7 +19552,7 @@ export class OrcaRuntimeService { // Fall through to the caller-provided path. The fetch still runs from // repoPath; this key only controls cache sharing. } - setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, resolved, REMOTE_FETCH_CACHE_MAX) + this.canonicalFetchKeyCache.set(cacheKey, resolved) return resolved } @@ -19572,7 +19577,7 @@ export class OrcaRuntimeService { return null } if (Date.now() - lastAt < FETCH_FRESHNESS_MS) { - setBoundedMapEntry(this.fetchLastCompletedAt, key, lastAt, REMOTE_FETCH_CACHE_MAX) + this.fetchLastCompletedAt.set(key, lastAt) return lastAt } this.fetchLastCompletedAt.delete(key) @@ -19580,7 +19585,7 @@ export class OrcaRuntimeService { } private rememberFreshFetchCompletedAt(key: string, completedAt = Date.now()): void { - setBoundedMapEntry(this.fetchLastCompletedAt, key, completedAt, REMOTE_FETCH_CACHE_MAX) + this.fetchLastCompletedAt.set(key, completedAt) } async getOrStartRemoteFetch( @@ -30447,20 +30452,6 @@ const REMOTE_FETCH_TIMEOUT_MS = 60_000 const REMOTE_FETCH_CACHE_MAX = 512 const DRIFT_PROBE_SUBJECT_LIMIT = 5 -function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { - if (map.has(key)) { - map.delete(key) - } - map.set(key, value) - while (map.size > maxEntries) { - const oldest = map.keys().next() - if (oldest.done) { - return - } - map.delete(oldest.value) - } -} - function getExplicitWorktreeIdSelector(selector: string | undefined): string | null { if (!selector?.startsWith('id:')) { return null diff --git a/src/renderer/src/components/terminal-pane/pty-eager-buffer-clamp.ts b/src/renderer/src/components/terminal-pane/pty-eager-buffer-clamp.ts index 0d4087e8288..347dd8372ac 100644 --- a/src/renderer/src/components/terminal-pane/pty-eager-buffer-clamp.ts +++ b/src/renderer/src/components/terminal-pane/pty-eager-buffer-clamp.ts @@ -1,4 +1,4 @@ -import { clampUtf8TextTail } from '../../../../shared/utf8-byte-limits' +import { clampUtf8TextTail } from '../../../../shared/memory-safety/utf8-byte-limits' export type EagerBufferChunk = { data: string diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index 7acbd6a5ff9..ae10944f857 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -1,7 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' import type { TerminalLayoutSnapshot } from '../../../../shared/types' -import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' +import { getUtf8ByteLength } from '../../../../shared/memory-safety/utf8-byte-limits' const LEAF_ID = '11111111-1111-4111-8111-111111111111' as const const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' as const diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index 4e6d1698787..f6c1a228988 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -7,7 +7,7 @@ import { mergeCapturedLeafState } from './merge-captured-leaf-state' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' -import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' +import { measureUtf8ByteLength } from '../../../../shared/memory-safety/utf8-byte-limits' const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 3f043a0a245..7450a3854e1 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -64,7 +64,7 @@ import { resolveGrokSessionsDir } from './grok-session-paths' import { sweepStaleAgentHookEndpointTemps } from './agent-hook-endpoint-temp-cleanup' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' /** Maximum request body size accepted by the listener (1 MB). */ export const HOOK_REQUEST_MAX_BYTES = 1_000_000 diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 1968ee0890f..b2367b74d59 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -9,7 +9,7 @@ import { normalizeOptionalMultilineField, normalizePromptField } from './agent-status-field-normalization' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' diff --git a/src/shared/browser-screencast-protocol.ts b/src/shared/browser-screencast-protocol.ts index 842fe86cb3d..e87b49ca1ae 100644 --- a/src/shared/browser-screencast-protocol.ts +++ b/src/shared/browser-screencast-protocol.ts @@ -1,4 +1,4 @@ -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' const BROWSER_SCREENCAST_KIND = 0x62 const BROWSER_SCREENCAST_VERSION = 1 diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 98ed09d681e..29244caa03e 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -1,6 +1,6 @@ import type { TuiAgent } from './types' import { isTuiAgentEnabled } from './tui-agent-selection' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' /* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */ diff --git a/src/shared/custom-pet-media-limits.ts b/src/shared/custom-pet-media-limits.ts deleted file mode 100644 index f3778069e40..00000000000 --- a/src/shared/custom-pet-media-limits.ts +++ /dev/null @@ -1,19 +0,0 @@ -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/ephemeral-vm-recipes.ts b/src/shared/ephemeral-vm-recipes.ts index 0f68bc92f84..07ca904f4c6 100644 --- a/src/shared/ephemeral-vm-recipes.ts +++ b/src/shared/ephemeral-vm-recipes.ts @@ -5,7 +5,7 @@ import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from './ssh-types' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' // Why: ephemeral-vm-recipe-doctor imports Node's fs/path, so it must NOT be // re-exported through this barrel — the renderer/web-client imports this module // and would otherwise pull Node built-ins into the browser bundle (build fails). diff --git a/src/shared/ephemeral-vm-runtime-store.ts b/src/shared/ephemeral-vm-runtime-store.ts index ad26bb2950e..167ceef81d4 100644 --- a/src/shared/ephemeral-vm-runtime-store.ts +++ b/src/shared/ephemeral-vm-runtime-store.ts @@ -1,8 +1,8 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' -import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' -import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' +import { JsonStringifyByteLimitError } from './memory-safety/node-bounded-json-stringify' +import { readNodeFileSyncWithinLimit } from './memory-safety/node-bounded-file-reader' +import { writeSecureJsonFileWithinLimit } from './memory-safety/bounded-secure-json-file' import { hardenExistingSecureFile } from './secure-file' import { EphemeralVmRuntimeRecordSchema, diff --git a/src/shared/external-automation-jobs-file.ts b/src/shared/external-automation-jobs-file.ts index 1013b8a0a53..97007d36caa 100644 --- a/src/shared/external-automation-jobs-file.ts +++ b/src/shared/external-automation-jobs-file.ts @@ -1,8 +1,8 @@ -import { NodeFileReadTooLargeError, readNodeFileWithinLimit } from './node-bounded-file-reader' +import { NodeFileReadTooLargeError, readNodeFileWithinLimit } from './memory-safety/node-bounded-file-reader' import { assertJsonTextStructureWithinLimits, type JsonTextStructureLimits -} from './json-text-structure-limit' +} from './memory-safety/json-text-structure-limit' export const EXTERNAL_AUTOMATION_JOBS_FILE_MAX_BYTES = 8 * 1024 * 1024 export const EXTERNAL_AUTOMATION_JOBS_MAX_ENTRIES = 10_000 diff --git a/src/shared/fetch-response-body.ts b/src/shared/fetch-response-body.ts index e9467fd3a2d..bdd168dde35 100644 --- a/src/shared/fetch-response-body.ts +++ b/src/shared/fetch-response-body.ts @@ -1,7 +1,7 @@ import { assertJsonTextStructureWithinLimits, type JsonTextStructureLimits -} from './json-text-structure-limit' +} from './memory-safety/json-text-structure-limit' const INITIAL_RESPONSE_CAPACITY_BYTES = 64 * 1024 diff --git a/src/shared/filesystem-directory-listing-limit.test.ts b/src/shared/filesystem-directory-listing-limit.test.ts deleted file mode 100644 index 73e9feaadf7..00000000000 --- a/src/shared/filesystem-directory-listing-limit.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index c43f0eebbcf..00000000000 --- a/src/shared/filesystem-directory-listing-limit.ts +++ /dev/null @@ -1,75 +0,0 @@ -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/git-uncommitted-line-stats.test.ts b/src/shared/git-uncommitted-line-stats.test.ts index 0e2d549868f..8c50f0676a7 100644 --- a/src/shared/git-uncommitted-line-stats.test.ts +++ b/src/shared/git-uncommitted-line-stats.test.ts @@ -7,7 +7,7 @@ const { lstatMock, readFileMock } = vi.hoisted(() => ({ vi.mock('fs/promises', () => ({ lstat: lstatMock })) -vi.mock('./node-bounded-file-reader', () => ({ +vi.mock('./memory-safety/node-bounded-file-reader', () => ({ readNodeFileWithinLimit: async (path: string) => ({ buffer: await readFileMock(path), stats: mockFileStat(0) diff --git a/src/shared/git-uncommitted-line-stats.ts b/src/shared/git-uncommitted-line-stats.ts index 3896d9354c3..e13ceb8f730 100644 --- a/src/shared/git-uncommitted-line-stats.ts +++ b/src/shared/git-uncommitted-line-stats.ts @@ -4,7 +4,7 @@ import { isBinaryBuffer } from './binary-buffer' import { decodeGitCQuotedPath } from './git-cquoted-path' import { DEFAULT_GIT_STATUS_LIMIT } from './git-status-limit' import { iterateNulDelimitedFields } from './nul-delimited-fields' -import { readNodeFileWithinLimit } from './node-bounded-file-reader' +import { readNodeFileWithinLimit } from './memory-safety/node-bounded-file-reader' export type GitLineStats = { added?: number; removed?: number } diff --git a/src/shared/hermes-run-output-limits.test.ts b/src/shared/hermes-run-output-limits.test.ts deleted file mode 100644 index 1f583a83ca9..00000000000 --- a/src/shared/hermes-run-output-limits.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index 99d16feaff0..00000000000 --- a/src/shared/hermes-run-output-limits.ts +++ /dev/null @@ -1,254 +0,0 @@ -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 deleted file mode 100644 index 669d41bda99..00000000000 --- a/src/shared/hermes-run-ref-retention.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 15aaefe3b13..00000000000 --- a/src/shared/hermes-run-ref-retention.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 061d60eb430..00000000000 --- a/src/shared/html-to-pdf-memory-limit.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { measureUtf8ByteLength } from './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 deleted file mode 100644 index 9d382c7a31d..00000000000 --- a/src/shared/linux-proc-port-scan-limits.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index dd1f9fd5c6e..00000000000 --- a/src/shared/linux-proc-port-scan-limits.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { readNodeFileWithinLimit } from './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/markdown-document-listing-limits.ts b/src/shared/markdown-document-listing-limits.ts index 9c9b39c49b3..5c9f111c820 100644 --- a/src/shared/markdown-document-listing-limits.ts +++ b/src/shared/markdown-document-listing-limits.ts @@ -1,5 +1,5 @@ import type { MarkdownDocument } from './types' -import { measureUtf8ByteLength } from './utf8-byte-limits' +import { measureUtf8ByteLength } from './memory-safety/utf8-byte-limits' export const MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS = 20_000 export const MARKDOWN_DOCUMENT_LISTING_MAX_METADATA_BYTES = 8 * 1024 * 1024 diff --git a/src/shared/mcp-config-inspection-limits.ts b/src/shared/mcp-config-inspection-limits.ts index 221a350825a..cd717dcdd5f 100644 --- a/src/shared/mcp-config-inspection-limits.ts +++ b/src/shared/mcp-config-inspection-limits.ts @@ -1,4 +1,4 @@ -import { measureUtf8ByteLength } from './utf8-byte-limits' +import { measureUtf8ByteLength } from './memory-safety/utf8-byte-limits' export const MCP_CONFIG_INSPECTION_MAX_BYTES = 256 * 1024 export const MCP_CONFIG_INSPECTION_MAX_CODE_UNITS = 256 * 1024 diff --git a/src/shared/memory-safety/README.md b/src/shared/memory-safety/README.md new file mode 100644 index 00000000000..0946e10f21d --- /dev/null +++ b/src/shared/memory-safety/README.md @@ -0,0 +1,58 @@ +# Memory safety + +Shared primitives that stop unbounded memory growth. Renderer OOM is Orca's dominant crash class +(#9984): heap climbs to V8's ~3.5 GB ceiling and the process is killed. The reported reproduction +(#9872) was a single unbounded `Map` that also spread-copied itself on every update. + +**Read this before adding a new limit, and before writing code that reads or accumulates data.** + +## The rule: bound at ingress, not in the interior + +Put a ceiling where untrusted or unpredictably-large data first enters memory: + +- **Files** — anything read from disk, a repo, or a remote host +- **Transport** — IPC, WebSocket frames, relay/SSH payloads, subprocess output +- **Parsing** — JSON/YAML/CSV/notebook text, base64, image headers +- **Enumeration** — directory walks, provider pagination, session scans +- **Long-lived accumulators** — caches and maps keyed by something a user can grow without limit + (pane IDs, session IDs, paths) that live for the process lifetime + +Do **not** add ceilings to interior data structures whose size is already implied by a bounded +input. A second ceiling behind an existing one is not defense in depth; it is a second thing that +can be wrong, and every ceiling is a new user-visible failure mode when it is hit. + +If you cannot name the untrusted input and the bound it crosses, you do not need a limit. + +## What's here + +| Module | Use it for | +| --- | --- | +| `bounded-map.ts` | A cache/map that must not grow forever. Count and/or byte ceiling with LRU eviction. | +| `node-bounded-file-reader.ts` | Reading a file whose size you do not control. Checks before allocating and while growing. | +| `node-bounded-json-stringify.ts` | Serializing to JSON when the result could be huge. | +| `bounded-secure-json-file.ts` | Reading/writing a secret-bearing JSON file within a byte ceiling. | +| `json-text-structure-limit.ts` | Guarding `JSON.parse` against depth/token amplification before it runs. | +| `utf8-byte-limits.ts` | Measuring/truncating UTF-8 without materializing the whole string. | + +Domain-specific ceilings (e.g. `git-status-limit.ts`, `terminal-scrollback-limits.ts`) live next to +the code they bound, not here. This folder is only for primitives reused across domains. + +## Rules for a new ceiling + +1. **Set it far above real use.** A limit a normal user reaches is a bug. Ordinary large repos, long + terminal sessions, and big diffs must pass untouched. State the headroom in a comment. +2. **Fail closed, and never crash a remote host.** An unmeasurable size rejects the item; it must not + poison a ledger or throw into a relay running on someone else's machine. +3. **Decide overload behavior explicitly** — reject, evict, or truncate — and document which. + Silent truncation of user data is the worst option; prefer a visible, recoverable failure. +4. **Release on every exit path**, including throw, abort, disconnect, and renderer teardown. Pair + acquisition with `try`/`finally`; do not rely on the happy path. +5. **Test the exact boundary and boundary+1**, through every entry point — including hydration, + restore, and replay paths, not just the create path. + +## Before you add one + +Prefer fixing the actual growth. The real crash was fixed by capping one map that was already known +to grow, found via the heap-highwater breadcrumbs in `renderer_memory_highwater` (#9984). Measure +first: if you cannot point at evidence that something grows, a new ceiling is speculation, and +speculation here costs review burden, user-visible failure modes, and code that outlives its reason. diff --git a/src/shared/bounded-map.test.ts b/src/shared/memory-safety/bounded-map.test.ts similarity index 100% rename from src/shared/bounded-map.test.ts rename to src/shared/memory-safety/bounded-map.test.ts diff --git a/src/shared/bounded-map.ts b/src/shared/memory-safety/bounded-map.ts similarity index 100% rename from src/shared/bounded-map.ts rename to src/shared/memory-safety/bounded-map.ts diff --git a/src/shared/bounded-secure-json-file.ts b/src/shared/memory-safety/bounded-secure-json-file.ts similarity index 85% rename from src/shared/bounded-secure-json-file.ts rename to src/shared/memory-safety/bounded-secure-json-file.ts index c9da2b9ce6f..46a5b1ec706 100644 --- a/src/shared/bounded-secure-json-file.ts +++ b/src/shared/memory-safety/bounded-secure-json-file.ts @@ -1,5 +1,5 @@ import { stringifyJsonWithinByteLimit } from './node-bounded-json-stringify' -import { writeSecureFile } from './secure-file' +import { writeSecureFile } from '../secure-file' export function writeSecureJsonFileWithinLimit( targetPath: string, diff --git a/src/shared/json-text-structure-limit.test.ts b/src/shared/memory-safety/json-text-structure-limit.test.ts similarity index 100% rename from src/shared/json-text-structure-limit.test.ts rename to src/shared/memory-safety/json-text-structure-limit.test.ts diff --git a/src/shared/json-text-structure-limit.ts b/src/shared/memory-safety/json-text-structure-limit.ts similarity index 100% rename from src/shared/json-text-structure-limit.ts rename to src/shared/memory-safety/json-text-structure-limit.ts diff --git a/src/shared/node-bounded-file-reader.test.ts b/src/shared/memory-safety/node-bounded-file-reader.test.ts similarity index 100% rename from src/shared/node-bounded-file-reader.test.ts rename to src/shared/memory-safety/node-bounded-file-reader.test.ts diff --git a/src/shared/node-bounded-file-reader.ts b/src/shared/memory-safety/node-bounded-file-reader.ts similarity index 100% rename from src/shared/node-bounded-file-reader.ts rename to src/shared/memory-safety/node-bounded-file-reader.ts diff --git a/src/shared/node-bounded-json-stringify.test.ts b/src/shared/memory-safety/node-bounded-json-stringify.test.ts similarity index 100% rename from src/shared/node-bounded-json-stringify.test.ts rename to src/shared/memory-safety/node-bounded-json-stringify.test.ts diff --git a/src/shared/node-bounded-json-stringify.ts b/src/shared/memory-safety/node-bounded-json-stringify.ts similarity index 100% rename from src/shared/node-bounded-json-stringify.ts rename to src/shared/memory-safety/node-bounded-json-stringify.ts diff --git a/src/shared/utf8-byte-limits.ts b/src/shared/memory-safety/utf8-byte-limits.ts similarity index 100% rename from src/shared/utf8-byte-limits.ts rename to src/shared/memory-safety/utf8-byte-limits.ts diff --git a/src/shared/mobile-file-directory-limit.test.ts b/src/shared/mobile-file-directory-limit.test.ts deleted file mode 100644 index 2d2228c2af7..00000000000 --- a/src/shared/mobile-file-directory-limit.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 9860f078c8d..00000000000 --- a/src/shared/mobile-file-directory-limit.ts +++ /dev/null @@ -1,43 +0,0 @@ -// 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/node-bounded-file-reader-sync.test.ts b/src/shared/node-bounded-file-reader-sync.test.ts index 748969e13ba..82f4be44cd9 100644 --- a/src/shared/node-bounded-file-reader-sync.test.ts +++ b/src/shared/node-bounded-file-reader-sync.test.ts @@ -2,7 +2,7 @@ import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { NodeFileReadTooLargeError, readNodeFileSyncWithinLimit } from './node-bounded-file-reader' +import { NodeFileReadTooLargeError, readNodeFileSyncWithinLimit } from './memory-safety/node-bounded-file-reader' const tempDirectories: string[] = [] diff --git a/src/shared/orca-yaml-file-limit.ts b/src/shared/orca-yaml-file-limit.ts index 8f2641637a1..05f439cba38 100644 --- a/src/shared/orca-yaml-file-limit.ts +++ b/src/shared/orca-yaml-file-limit.ts @@ -1,4 +1,4 @@ -import { measureUtf8ByteLength } from './utf8-byte-limits' +import { measureUtf8ByteLength } from './memory-safety/utf8-byte-limits' export const MAX_ORCA_YAML_BYTES = 256 * 1024 export const MAX_ORCA_YAML_CODE_UNITS = 256 * 1024 diff --git a/src/shared/persisted-state-file-bounds.test.ts b/src/shared/persisted-state-file-bounds.test.ts deleted file mode 100644 index e6ee2de1878..00000000000 --- a/src/shared/persisted-state-file-bounds.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -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 './node-bounded-file-reader' -import { JsonStringifyByteLimitError } from './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 deleted file mode 100644 index 4fe8a977bb3..00000000000 --- a/src/shared/persisted-state-file-bounds.ts +++ /dev/null @@ -1,222 +0,0 @@ -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 './node-bounded-file-reader' -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from './node-bounded-json-stringify' -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from './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 deleted file mode 100644 index 8b695adede7..00000000000 --- a/src/shared/pr-refresh-memory-limits.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 fd927b82ae7..bc3fab88856 100644 --- a/src/shared/pull-request-generation.ts +++ b/src/shared/pull-request-generation.ts @@ -1,5 +1,5 @@ import { truncateDiffForPrompt } from './commit-message-prompt' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' export const GENERATED_PULL_REQUEST_JSON_STRUCTURE_LIMITS = { structuralTokens: 64, diff --git a/src/shared/quick-open-install-rg.ts b/src/shared/quick-open-install-rg.ts index 7815d7d6e34..f8fb07c98f4 100644 --- a/src/shared/quick-open-install-rg.ts +++ b/src/shared/quick-open-install-rg.ts @@ -1,4 +1,4 @@ -import { readNodeFileWithinLimit } from './node-bounded-file-reader' +import { readNodeFileWithinLimit } from './memory-safety/node-bounded-file-reader' import { getProcessOutputFields, iterateProcessOutputLines } from './process-output-field-scanner' const GENERIC_LINUX_RIPGREP_INSTALL = diff --git a/src/shared/quick-open-listing-limits.test.ts b/src/shared/quick-open-listing-limits.test.ts deleted file mode 100644 index bd24c80da6c..00000000000 --- a/src/shared/quick-open-listing-limits.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 0e28f8b1d3a..00000000000 --- a/src/shared/quick-open-listing-limits.ts +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index a9d256ba904..00000000000 --- a/src/shared/relay-json-admission.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { assertJsonTextStructureWithinLimits } from './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/relay-version-marker.test.ts b/src/shared/relay-version-marker.test.ts index 36955399518..6c6ca3c125e 100644 --- a/src/shared/relay-version-marker.test.ts +++ b/src/shared/relay-version-marker.test.ts @@ -2,7 +2,7 @@ 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 { NodeFileReadTooLargeError } from './node-bounded-file-reader' +import { NodeFileReadTooLargeError } from './memory-safety/node-bounded-file-reader' import { RELAY_VERSION_MARKER_MAX_BYTES, readRelayVersionMarkerSync } from './relay-version-marker' const roots: string[] = [] diff --git a/src/shared/relay-version-marker.ts b/src/shared/relay-version-marker.ts index 68be008ff8d..a74873f3486 100644 --- a/src/shared/relay-version-marker.ts +++ b/src/shared/relay-version-marker.ts @@ -1,4 +1,4 @@ -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' +import { readNodeFileSyncWithinLimit } from './memory-safety/node-bounded-file-reader' export const RELAY_VERSION_MARKER_MAX_BYTES = 4 * 1024 diff --git a/src/shared/remote-runtime-memory-limits.ts b/src/shared/remote-runtime-memory-limits.ts index 62cb62d03cf..2274d86d4f4 100644 --- a/src/shared/remote-runtime-memory-limits.ts +++ b/src/shared/remote-runtime-memory-limits.ts @@ -1,7 +1,7 @@ import { JsonStringifyByteLimitError, stringifyJsonWithinByteLimit -} from './node-bounded-json-stringify' +} from './memory-safety/node-bounded-json-stringify' import { RemoteRuntimeClientError } from './remote-runtime-client-error' export const REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES = 4 * 1024 * 1024 diff --git a/src/shared/remote-runtime-request-frames.ts b/src/shared/remote-runtime-request-frames.ts index 811ba8c26c9..e573e7feca4 100644 --- a/src/shared/remote-runtime-request-frames.ts +++ b/src/shared/remote-runtime-request-frames.ts @@ -4,7 +4,7 @@ import { isKeepaliveFrame } from './runtime-rpc-envelope' import { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' export const REMOTE_RUNTIME_JSON_STRUCTURE_LIMITS = { structuralTokens: 256 * 1024, diff --git a/src/shared/runtime-bootstrap.ts b/src/shared/runtime-bootstrap.ts index efa224d749b..b299c539787 100644 --- a/src/shared/runtime-bootstrap.ts +++ b/src/shared/runtime-bootstrap.ts @@ -1,5 +1,5 @@ import { join } from 'node:path' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' export type RuntimeTransportMetadata = | { diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 4211eb18da8..e8d73547b3c 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -1,10 +1,10 @@ import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' import { join } from 'node:path' -import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' +import { JsonStringifyByteLimitError } from './memory-safety/node-bounded-json-stringify' +import { readNodeFileSyncWithinLimit } from './memory-safety/node-bounded-file-reader' import { parsePairingCode, type PairingOffer } from './pairing' -import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' +import { writeSecureJsonFileWithinLimit } from './memory-safety/bounded-secure-json-file' import { hardenExistingSecureFile } from './secure-file' import { createEnvironmentFromPairingOffer, diff --git a/src/shared/secure-path-hardening-cache.ts b/src/shared/secure-path-hardening-cache.ts index 3ec32de4f8b..ecaf1ef4df3 100644 --- a/src/shared/secure-path-hardening-cache.ts +++ b/src/shared/secure-path-hardening-cache.ts @@ -1,74 +1,49 @@ +import { BoundedMap } from './memory-safety/bounded-map' + export type SecurePathHardeningCacheBounds = { maxEntries: number maxKeyBytes: number maxTotalKeyBytes: number } -type RetainedSecurePath = { - value: T - keyBytes: number -} - +// Key-byte-weighted LRU over the shared BoundedMap. The wrapper keeps two behaviors a generic map +// does not model: a path over EITHER the per-key or the aggregate ceiling is rejected, and the cache +// is disabled outright when maxEntries <= 0. export class SecurePathHardeningCache { - private readonly entries = new Map>() - private retainedKeyBytes = 0 + private readonly disabled: boolean + private readonly map: BoundedMap - constructor(private readonly bounds: SecurePathHardeningCacheBounds) {} + constructor(bounds: SecurePathHardeningCacheBounds) { + this.disabled = bounds.maxEntries <= 0 + this.map = new BoundedMap({ + maxEntries: Math.max(1, bounds.maxEntries), + maxBytes: bounds.maxTotalKeyBytes, + maxEntryBytes: Math.min(bounds.maxKeyBytes, bounds.maxTotalKeyBytes), + sizeOf: (_value, path) => Buffer.byteLength(path, 'utf8') + }) + } get(path: string): T | undefined { - const retained = this.entries.get(path) - if (!retained) { - return undefined - } - this.entries.delete(path) - this.entries.set(path, retained) - return retained.value + return this.map.get(path) } set(path: string, value: T): boolean { - const keyBytes = Buffer.byteLength(path, 'utf8') - this.delete(path) - if ( - keyBytes > this.bounds.maxKeyBytes || - keyBytes > this.bounds.maxTotalKeyBytes || - this.bounds.maxEntries <= 0 - ) { - return false - } - while ( - this.entries.size >= this.bounds.maxEntries || - this.retainedKeyBytes + keyBytes > this.bounds.maxTotalKeyBytes - ) { - const oldest = this.entries.keys().next().value - if (oldest === undefined) { - return false - } - this.delete(oldest) - } - this.entries.set(path, { value, keyBytes }) - this.retainedKeyBytes += keyBytes - return true + return this.disabled ? false : this.map.set(path, value) } delete(path: string): void { - const retained = this.entries.get(path) - if (!retained) { - return - } - this.entries.delete(path) - this.retainedKeyBytes -= retained.keyBytes + this.map.delete(path) } clear(): void { - this.entries.clear() - this.retainedKeyBytes = 0 + this.map.clear() } state(): { entries: number; keyBytes: number; paths: string[] } { return { - entries: this.entries.size, - keyBytes: this.retainedKeyBytes, - paths: [...this.entries.keys()] + entries: this.map.size, + keyBytes: this.map.retainedBytes, + paths: this.map.keys() } } } diff --git a/src/shared/serve-update-handoff.ts b/src/shared/serve-update-handoff.ts index 3dddd2a38da..05bb5b3b9a4 100644 --- a/src/shared/serve-update-handoff.ts +++ b/src/shared/serve-update-handoff.ts @@ -1,5 +1,5 @@ import { join } from 'node:path' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' export const SERVE_UPDATE_HANDOFF_PATH_ENV = 'ORCA_SERVE_UPDATE_HANDOFF_PATH' export const SERVE_UPDATE_HANDOFF_FILE = 'serve-update-handoff.json' diff --git a/src/shared/setup-script-import-codex-environment.ts b/src/shared/setup-script-import-codex-environment.ts index 77a8e48296d..543e20ee089 100644 --- a/src/shared/setup-script-import-codex-environment.ts +++ b/src/shared/setup-script-import-codex-environment.ts @@ -6,7 +6,7 @@ import { SETUP_SCRIPT_IMPORT_MAX_TOML_LINES, SETUP_SCRIPT_IMPORT_MAX_UNSUPPORTED_FIELDS } from './setup-script-import-limits' -import { measureUtf8ByteLength } from './utf8-byte-limits' +import { measureUtf8ByteLength } from './memory-safety/utf8-byte-limits' const CODEX_ENVIRONMENT_PATH = '.codex/environments/environment.toml' diff --git a/src/shared/setup-script-import-limits.ts b/src/shared/setup-script-import-limits.ts index d409586db85..556310f3074 100644 --- a/src/shared/setup-script-import-limits.ts +++ b/src/shared/setup-script-import-limits.ts @@ -1,4 +1,4 @@ -import { measureUtf8ByteLength } from './utf8-byte-limits' +import { measureUtf8ByteLength } from './memory-safety/utf8-byte-limits' export const SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES = 256 * 1024 export const SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS = 256 * 1024 diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts deleted file mode 100644 index 92a4e6c90db..00000000000 --- a/src/shared/ssh-retained-payload-admission.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getUtf8ByteLength } from './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 deleted file mode 100644 index 21b51787a1d..00000000000 --- a/src/shared/ssh-retained-payload-admission.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { EnrichedDetectedPort, SshConnectionState, SshConnectionStatus } from './ssh-types' -import { clampUtf8TextPrefix, measureUtf8ByteLength } from './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 deleted file mode 100644 index fca3d9c8754..00000000000 --- a/src/shared/terminal-size-limits.ts +++ /dev/null @@ -1,34 +0,0 @@ -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/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 76e82eafce9..acb9c7b6ce8 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -1,4 +1,4 @@ -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' const TERMINAL_STREAM_KIND = 0x74 const TERMINAL_STREAM_VERSION = 1 diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index d3d9b40226a..74c7080624f 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -11,7 +11,7 @@ * Design doc: docs/design/share-text-search.md. */ import { posix, win32 } from 'node:path' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { assertJsonTextStructureWithinLimits } from './memory-safety/json-text-structure-limit' import { normalizeSearchResult } from './search-match-count' import { escapeRegex } from './string-utils' import type { SearchFileResult, SearchMatch, SearchOptions, SearchResult } from './types' diff --git a/src/shared/workspace-session-terminal-buffers.test.ts b/src/shared/workspace-session-terminal-buffers.test.ts index e5c868cd691..f480b47feb2 100644 --- a/src/shared/workspace-session-terminal-buffers.test.ts +++ b/src/shared/workspace-session-terminal-buffers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import type { WorkspaceSessionState } from './types' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from './terminal-scrollback-limits' -import { getUtf8ByteLength } from './utf8-byte-limits' +import { getUtf8ByteLength } from './memory-safety/utf8-byte-limits' import { pruneLocalTerminalScrollbackBuffers, shouldPreserveTerminalScrollbackBuffers diff --git a/src/shared/workspace-session-terminal-buffers.ts b/src/shared/workspace-session-terminal-buffers.ts index 81ab8315c72..503074a12ea 100644 --- a/src/shared/workspace-session-terminal-buffers.ts +++ b/src/shared/workspace-session-terminal-buffers.ts @@ -2,7 +2,7 @@ import type { Repo, WorkspaceSessionState } from './types' import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import { getRepoIdFromWorktreeId } from './worktree-id' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from './terminal-scrollback-limits' -import { clampUtf8TextTail, measureUtf8ByteLength } from './utf8-byte-limits' +import { clampUtf8TextTail, measureUtf8ByteLength } from './memory-safety/utf8-byte-limits' import { parseExecutionHostId } from './execution-host' export type RepoConnection = Pick